From 3883031c0b71e4096be32ee6e3b6c5ab62906cda Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Thu, 14 May 2026 17:41:50 +0900 Subject: [PATCH] =?UTF-8?q?feat(studio):=20Phase=20G=20=E2=80=94=20KPI=20s?= =?UTF-8?q?tats=20/=20chart=20/=20cardList=20/=20groupedTable=20+=20canoni?= =?UTF-8?q?cal=20container=20tabs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../controller/TableManagementController.java | 51 + .../erp/service/TableManagementService.java | 363 +++ frontend/app/(main)/admin/builder/page.tsx | 6 +- .../admin/screenMng/screenMngList/page.tsx | 4 +- .../{ScreenDesigner.tsx => InvyoneStudio.tsx} | 347 ++- .../components/screen/ScreenSettingModal.tsx | 8 +- .../config-panels/button-config/ActionTab.tsx | 20 +- .../screen/config-panels/button/DataTab.tsx | 20 +- .../screen/panels/ComponentsPanel.tsx | 3 +- .../screen/panels/V2PropertiesPanel.tsx | 4 - .../v2/config-panels/InvFieldConfigPanel.tsx | 74 +- frontend/lib/api/stats.ts | 160 ++ .../lib/registry/DynamicWebTypeRenderer.tsx | 84 +- .../components/_shared/ColumnPicker.tsx | 81 + .../registry/components/_shared/FilterRow.tsx | 260 +++ .../components/_shared/use-table-rows.ts | 211 ++ .../card-list/CardListComponent.tsx | 346 +++ .../components/card-list/CardListRenderer.tsx | 23 + .../card-list/InvCardListConfigPanel.tsx | 472 ++++ .../registry/components/card-list/index.ts | 44 + .../registry/components/card-list/types.ts | 47 + .../components/chart/ChartComponent.tsx | 295 +++ .../components/chart/ChartRenderer.tsx | 27 + .../components/chart/InvChartConfigPanel.tsx | 264 +++ .../lib/registry/components/chart/index.ts | 51 + .../lib/registry/components/chart/types.ts | 48 + .../components/chart/use-chart-data.ts | 208 ++ .../registry/components/common/IconPicker.tsx | 323 ++- .../components/common/useDbColumns.ts | 89 + .../container/ContainerComponent.tsx | 325 ++- .../container/InvContainerConfigPanel.tsx | 14 + .../registry/components/container/types.ts | 36 + .../grouped-table/GroupedTableComponent.tsx | 329 +++ .../grouped-table/GroupedTableRenderer.tsx | 23 + .../InvGroupedTableConfigPanel.tsx | 362 +++ .../components/grouped-table/index.ts | 48 + .../components/grouped-table/types.ts | 44 + frontend/lib/registry/components/index.ts | 3 + .../components/input/InputComponent.tsx | 69 +- .../components/input/select-pickers.tsx | 38 +- .../components/stats/InvStatsConfigPanel.tsx | 736 ++++-- .../components/stats/StatsComponent.tsx | 141 +- .../lib/registry/components/stats/index.ts | 12 +- .../lib/registry/components/stats/types.ts | 34 +- .../components/stats/use-stats-data.ts | 259 +++ .../lib/utils/getComponentConfigPanel.tsx | 8 +- .../2026-05-14-container-twin/index.html | 1519 +++++++++++++ .../2026-05-14-invyone-intro/agent-demo.html | 1984 +++++++++++++++++ .../2026-05-14-invyone-intro/index.html | 747 +++++++ .../2026-05-14-invyone-intro/studio-demo.html | 1611 +++++++++++++ .../2026-05-14-studio-data-view-roadmap.md | 1611 +++++++++++++ 51 files changed, 13555 insertions(+), 331 deletions(-) rename frontend/components/screen/{ScreenDesigner.tsx => InvyoneStudio.tsx} (95%) create mode 100644 frontend/lib/api/stats.ts create mode 100644 frontend/lib/registry/components/_shared/ColumnPicker.tsx create mode 100644 frontend/lib/registry/components/_shared/FilterRow.tsx create mode 100644 frontend/lib/registry/components/_shared/use-table-rows.ts create mode 100644 frontend/lib/registry/components/card-list/CardListComponent.tsx create mode 100644 frontend/lib/registry/components/card-list/CardListRenderer.tsx create mode 100644 frontend/lib/registry/components/card-list/InvCardListConfigPanel.tsx create mode 100644 frontend/lib/registry/components/card-list/index.ts create mode 100644 frontend/lib/registry/components/card-list/types.ts create mode 100644 frontend/lib/registry/components/chart/ChartComponent.tsx create mode 100644 frontend/lib/registry/components/chart/ChartRenderer.tsx create mode 100644 frontend/lib/registry/components/chart/InvChartConfigPanel.tsx create mode 100644 frontend/lib/registry/components/chart/index.ts create mode 100644 frontend/lib/registry/components/chart/types.ts create mode 100644 frontend/lib/registry/components/chart/use-chart-data.ts create mode 100644 frontend/lib/registry/components/common/useDbColumns.ts create mode 100644 frontend/lib/registry/components/grouped-table/GroupedTableComponent.tsx create mode 100644 frontend/lib/registry/components/grouped-table/GroupedTableRenderer.tsx create mode 100644 frontend/lib/registry/components/grouped-table/InvGroupedTableConfigPanel.tsx create mode 100644 frontend/lib/registry/components/grouped-table/index.ts create mode 100644 frontend/lib/registry/components/grouped-table/types.ts create mode 100644 frontend/lib/registry/components/stats/use-stats-data.ts create mode 100644 notes/gbpark/2026-05-14-container-twin/index.html create mode 100644 notes/gbpark/2026-05-14-invyone-intro/agent-demo.html create mode 100644 notes/gbpark/2026-05-14-invyone-intro/index.html create mode 100644 notes/gbpark/2026-05-14-invyone-intro/studio-demo.html create mode 100644 notes/gbpark/2026-05-14-studio-data-view-roadmap.md diff --git a/backend-spring/src/main/java/com/erp/controller/TableManagementController.java b/backend-spring/src/main/java/com/erp/controller/TableManagementController.java index 76346b8a..46c1e909 100644 --- a/backend-spring/src/main/java/com/erp/controller/TableManagementController.java +++ b/backend-spring/src/main/java/com/erp/controller/TableManagementController.java @@ -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>> aggregateTableData( + @PathVariable String tableName, + @RequestBody Map 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>> aggregateTableGroup( + @PathVariable String tableName, + @RequestBody Map 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>> selectTableRows( + @PathVariable String tableName, + @RequestBody Map 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>> getTableRecord( diff --git a/backend-spring/src/main/java/com/erp/service/TableManagementService.java b/backend-spring/src/main/java/com/erp/service/TableManagementService.java index eb8d0d1e..4e67c56d 100644 --- a/backend-spring/src/main/java/com/erp/service/TableManagementService.java +++ b/backend-spring/src/main/java/com/erp/service/TableManagementService.java @@ -455,6 +455,369 @@ public class TableManagementService extends BaseService { return result; } + // ────────────────────────────────────────────────── + // 동적 테이블 집계 (count / sum / avg / min / max / distinctCount) + // ────────────────────────────────────────────────── + + private static final Set AGG_TYPES = Set.of( + "count", "sum", "avg", "min", "max", "distinctCount" + ); + + private static final Set FILTER_OPS = Set.of( + "=", "!=", ">", "<", ">=", "<=", + "like", "in", "notIn", "isNull", "isNotNull" + ); + + /** + * 단일 집계 값 계산. + * + * count — column 없이도 동작 (COUNT(*)) + * sum/avg/min/max — column 필수 + * distinctCount — column 필수 (COUNT(DISTINCT col)) + */ + public Map aggregateTableData(String tableName, Map 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> filters = normalizeAggregateFilters(options.get("filters")); + + List 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 result = new LinkedHashMap<>(); + result.put("value", value); + return result; + } + + private String buildAggregateWhere(String safeTable, List> filters, List values) { + if (filters == null || filters.isEmpty()) return ""; + List clauses = new ArrayList<>(); + for (Map 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 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> normalizeAggregateFilters(Object rawFilters) { + if (!(rawFilters instanceof List rawList) || rawList.isEmpty()) { + return Collections.emptyList(); + } + List> out = new ArrayList<>(); + for (Object item : rawList) { + if (item instanceof Map rawMap) { + Map 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 toList(Object val) { + if (val == null) return List.of(); + if (val instanceof List l) { + List 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 aggregateTableGroup(String tableName, Map 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> 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 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> rawRows = jdbcTemplate.queryForList(sql, values.toArray()); + List> rows = new ArrayList<>(); + for (Map r : rawRows) { + Object groupVal = r.get("group_value"); + Object aggVal = r.get("agg_value"); + double value = aggVal instanceof Number ? ((Number) aggVal).doubleValue() : 0d; + Map out = new LinkedHashMap<>(); + out.put("group", groupVal); + out.put("value", value); + rows.add(out); + } + + Map 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 selectTableRows(String tableName, Map options) { + String safeTable = sanitize(tableName); + if (safeTable.isBlank() || !checkTableExists(safeTable)) { + throw new IllegalArgumentException("테이블이 존재하지 않습니다: " + tableName); + } + + @SuppressWarnings("unchecked") + List rawColumns = options.get("columns") instanceof List raw + ? (List) raw : Collections.emptyList(); + + List 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> filters = normalizeAggregateFilters(options.get("filters")); + + List values = new ArrayList<>(); + String where = buildAggregateWhere(safeTable, filters, values); + + // orderBy: [{ column, direction }] + List> orderBy = normalizeAggregateFilters(options.get("orderBy")); + + List orderClauses = new ArrayList<>(); + for (Map 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> rows = jdbcTemplate.queryForList(sql, values.toArray()); + + Map result = new LinkedHashMap<>(); + result.put("rows", rows); + return result; + } + @Transactional public Map addTableData(String tableName, Map data) { String safeTable = sanitize(tableName); diff --git a/frontend/app/(main)/admin/builder/page.tsx b/frontend/app/(main)/admin/builder/page.tsx index 9912ba99..1c0d80bf 100644 --- a/frontend/app/(main)/admin/builder/page.tsx +++ b/frontend/app/(main)/admin/builder/page.tsx @@ -3,10 +3,10 @@ // INVYONE 스튜디오 진입 페이지 (templates 테이블 기반) // - 템플릿 목록 + 새 템플릿 생성 → templates 테이블 CRUD // - URL ?id= 로 바로 진입 -// - ScreenDesigner 는 template_id 를 통해 templates API 로 저장/로드 +// - InvyoneStudio 는 template_id 를 통해 templates API 로 저장/로드 import { Suspense, useState, useEffect, useCallback } from "react"; import { useSearchParams, useRouter } from "next/navigation"; -import ScreenDesigner from "@/components/screen/ScreenDesigner"; +import InvyoneStudio from "@/components/screen/InvyoneStudio"; import type { ScreenDefinition } from "@/types/screen"; import { getTemplateList, deleteTemplate } from "@/lib/api/template"; import { createTemplate } from "@/lib/utils/templateAdapter"; @@ -442,7 +442,7 @@ function BuilderInner() { return (
- { diff --git a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx index b7dd0f0e..73ee4914 100644 --- a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx +++ b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx @@ -5,7 +5,7 @@ import { useSearchParams } from "next/navigation"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Plus, RefreshCw, Search, X, LayoutGrid, LayoutList, TestTube2, Database, MoreHorizontal, PanelLeftClose, PanelLeftOpen } from "lucide-react"; -import ScreenDesigner from "@/components/screen/ScreenDesigner"; +import InvyoneStudio from "@/components/screen/InvyoneStudio"; import TemplateManager from "@/components/screen/TemplateManager"; import { ScreenGroupTreeView } from "@/components/screen/ScreenGroupTreeView"; import { ScreenRelationFlow } from "@/components/screen/ScreenRelationFlow"; @@ -138,7 +138,7 @@ export default function ScreenManagementPage() { if (isDesignMode) { return (
- goToStep("list")} onScreenUpdate={(updatedFields) => { diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/InvyoneStudio.tsx similarity index 95% rename from frontend/components/screen/ScreenDesigner.tsx rename to frontend/components/screen/InvyoneStudio.tsx index 1b22a78c..457d13fb 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/InvyoneStudio.tsx @@ -147,7 +147,7 @@ import { type ViewType } from "./ViewTabBar"; // 컴포넌트 초기화 (새 시스템) import "@/lib/registry/components"; -interface ScreenDesignerProps { +interface InvyoneStudioProps { selectedScreen: ScreenDefinition | null; onBackToList: () => void; onScreenUpdate?: (updatedScreen: Partial) => void; @@ -453,13 +453,13 @@ function inferPrimaryTableFromComponents( return ""; } -export default function ScreenDesigner({ +export default function InvyoneStudio({ selectedScreen, onBackToList, onScreenUpdate, isPop = false, defaultDevicePreview = "tablet" -}: ScreenDesignerProps) { +}: InvyoneStudioProps) { // POP 모드 여부에 따른 API 분기 const USE_POP_API = isPop; @@ -3855,7 +3855,11 @@ export default function ScreenDesigner({ } const compType = (targetComponent as any)?.componentType; - if (targetComponent && (compType === "tabs-widget" || compType === "v2-tabs-widget")) { + // 🆕 canonical container 의 `containerType: "tabs"` 도 동일 분기로 처리 (Phase G.2.5) + const isCanonicalTabs = + compType === "container" && + ((targetComponent as any)?.componentConfig?.containerType ?? "section") === "tabs"; + if (targetComponent && (compType === "tabs-widget" || compType === "v2-tabs-widget" || isCanonicalTabs)) { const currentConfig = (targetComponent as any).componentConfig || {}; const tabs = currentConfig.tabs || []; @@ -3871,6 +3875,7 @@ export default function ScreenDesigner({ componentId: component.id, componentType: componentType, componentName: component.name, + kind: isCanonicalTabs ? "canonical-container" : compType, isNested: !!parentSplitPanelId, parentSplitPanelId, parentPanelSide, @@ -8774,7 +8779,339 @@ export default function ScreenDesigner({ }} />
- {selectedComponent ? ( + {/* + Phase G.2.6 — 우측 properties 패널 선택 우선순위: + 1) selectedTabComponentInfo (canonical container 의 탭 자식 / 옛 v2-tabs-widget 자식 모두) + 2) selectedPanelComponentInfo (분할 패널 안의 자식) + 3) selectedComponent (최상위 컴포넌트) + 4) 안내 메시지 + 1·2 분기는 옛 좌측 disabled 패널 (`{false && ...}`) 의 동일 로직을 이식. + nested setNestedValue 로 깊은 path (`componentConfig.dataSource.tableName`, + `size.width`, `style.labelDisplay` 등) 모두 처리. 정상 컴포넌트 편집과 + 같이 saveToHistory 까지 호출. + */} + {selectedTabComponentInfo ? ( + (() => { + const tabComp = selectedTabComponentInfo.component; + + const tabComponentAsComponentData = { + id: tabComp.id, + type: "component", + componentType: tabComp.componentType, + label: tabComp.label, + position: tabComp.position || { x: 0, y: 0 }, + size: tabComp.size || { width: 200, height: 100 }, + componentConfig: tabComp.componentConfig || {}, + style: tabComp.style || {}, + } as unknown as ComponentData; + + const setNestedValue = (obj: any, pathStr: string, val: any): any => { + const result = JSON.parse(JSON.stringify(obj)); + const parts = pathStr.split("."); + let current = result; + for (let i = 0; i < parts.length - 1; i++) { + const part = parts[i]; + if (!current[part] || typeof current[part] !== "object") { + current[part] = {}; + } + current = current[part]; + } + current[parts[parts.length - 1]] = val; + return result; + }; + + const updateTabsComponent = (tabsComponent: any, tabId: string, componentId: string, path: string, value: any) => { + const currentConfig = JSON.parse(JSON.stringify(tabsComponent.componentConfig || {})); + const tabs = currentConfig.tabs || []; + const updatedTabs = tabs.map((tab: any) => { + if (tab.id !== tabId) return tab; + return { + ...tab, + components: (tab.components || []).map((comp: any) => { + if (comp.id !== componentId) return comp; + return path === "style" + ? { ...comp, style: value } + : setNestedValue(comp, path, value); + }), + }; + }); + return { + ...tabsComponent, + componentConfig: { ...currentConfig, tabs: updatedTabs }, + }; + }; + + const updateTabChildProperty = (componentId: string, path: string, value: any) => { + const { tabsComponentId, tabId, parentSplitPanelId, parentPanelSide } = + selectedTabComponentInfo; + + setLayout((prevLayout) => { + let newLayout; + let updatedTabs: any[] | undefined; + + if (parentSplitPanelId && parentPanelSide) { + newLayout = { + ...prevLayout, + components: prevLayout.components.map((c) => { + if (c.id !== parentSplitPanelId) return c; + const splitConfig = (c as any).componentConfig || {}; + const panelKey = parentPanelSide === "left" ? "leftPanel" : "rightPanel"; + const panelConfig = splitConfig[panelKey] || {}; + const panelComponents = panelConfig.components || []; + const tabsComponent = panelComponents.find((pc: any) => pc.id === tabsComponentId); + if (!tabsComponent) return c; + const updatedTabsComponent = updateTabsComponent( + tabsComponent, tabId, componentId, path, value, + ); + updatedTabs = updatedTabsComponent.componentConfig.tabs; + return { + ...c, + componentConfig: { + ...splitConfig, + [panelKey]: { + ...panelConfig, + components: panelComponents.map((pc: any) => + pc.id === tabsComponentId ? updatedTabsComponent : pc, + ), + }, + }, + }; + }), + }; + } else { + const tabsComponent = prevLayout.components.find((c) => c.id === tabsComponentId); + if (!tabsComponent) return prevLayout; + const updatedTabsComponent = updateTabsComponent( + tabsComponent, tabId, componentId, path, value, + ); + updatedTabs = updatedTabsComponent.componentConfig.tabs; + newLayout = { + ...prevLayout, + components: prevLayout.components.map((c) => + c.id === tabsComponentId ? updatedTabsComponent : c, + ), + }; + } + + if (updatedTabs) { + const updatedComp = updatedTabs + .find((t: any) => t.id === tabId) + ?.components?.find((c: any) => c.id === componentId); + if (updatedComp) { + setSelectedTabComponentInfo((prev) => + prev ? { ...prev, component: updatedComp } : null, + ); + } + } + + saveToHistory(newLayout); + return newLayout; + }); + }; + + const deleteTabChild = (componentId: string) => { + const { tabsComponentId, tabId, parentSplitPanelId, parentPanelSide } = + selectedTabComponentInfo; + + const filterOut = (tabsComponent: any) => { + const currentConfig = tabsComponent.componentConfig || {}; + const tabs = currentConfig.tabs || []; + const updatedTabs = tabs.map((tab: any) => + tab.id === tabId + ? { ...tab, components: (tab.components || []).filter((c: any) => c.id !== componentId) } + : tab, + ); + return { + ...tabsComponent, + componentConfig: { ...currentConfig, tabs: updatedTabs }, + }; + }; + + setLayout((prevLayout) => { + let newLayout; + if (parentSplitPanelId && parentPanelSide) { + newLayout = { + ...prevLayout, + components: prevLayout.components.map((c) => { + if (c.id !== parentSplitPanelId) return c; + const splitConfig = (c as any).componentConfig || {}; + const panelKey = parentPanelSide === "left" ? "leftPanel" : "rightPanel"; + const panelConfig = splitConfig[panelKey] || {}; + const panelComponents = panelConfig.components || []; + const tabsComponent = panelComponents.find((pc: any) => pc.id === tabsComponentId); + if (!tabsComponent) return c; + const updatedTabsComponent = filterOut(tabsComponent); + return { + ...c, + componentConfig: { + ...splitConfig, + [panelKey]: { + ...panelConfig, + components: panelComponents.map((pc: any) => + pc.id === tabsComponentId ? updatedTabsComponent : pc, + ), + }, + }, + }; + }), + }; + } else { + const tabsComponent = prevLayout.components.find((c) => c.id === tabsComponentId); + if (!tabsComponent) return prevLayout; + const updatedTabsComponent = filterOut(tabsComponent); + newLayout = { + ...prevLayout, + components: prevLayout.components.map((c) => + c.id === tabsComponentId ? updatedTabsComponent : c, + ), + }; + } + setSelectedTabComponentInfo(null); + saveToHistory(newLayout); + return newLayout; + }); + }; + + return ( + 0 ? (tables[0] as any) : undefined} + currentTableName={selectedScreen?.table_name} + currentScreenCompanyCode={selectedScreen?.company_code} + onStyleChange={(style) => { + updateTabChildProperty(tabComp.id, "style", style); + }} + allComponents={layout.components} + menuObjid={menuObjid} + /> + ); + })() + ) : selectedPanelComponentInfo ? ( + (() => { + const panelComp = selectedPanelComponentInfo.component; + const { splitPanelId, panelSide } = selectedPanelComponentInfo; + const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel"; + + const panelComponentAsComponentData = { + id: panelComp.id, + type: "component", + componentType: panelComp.componentType, + label: panelComp.label, + position: panelComp.position || { x: 0, y: 0 }, + size: panelComp.size || { width: 200, height: 100 }, + componentConfig: panelComp.componentConfig || {}, + style: panelComp.style || {}, + } as unknown as ComponentData; + + const setNestedValue = (obj: any, pathStr: string, val: any): any => { + const result = JSON.parse(JSON.stringify(obj)); + const parts = pathStr.split("."); + let current = result; + for (let i = 0; i < parts.length - 1; i++) { + const part = parts[i]; + if (!current[part] || typeof current[part] !== "object") { + current[part] = {}; + } + current = current[part]; + } + current[parts[parts.length - 1]] = val; + return result; + }; + + const updatePanelChildProperty = (componentId: string, path: string, value: any) => { + setLayout((prevLayout) => { + const splitPanelComponent = prevLayout.components.find( + (c: any) => c.id === splitPanelId, + ); + if (!splitPanelComponent) return prevLayout; + const currentConfig = (splitPanelComponent as any).componentConfig || {}; + const panelConfig = currentConfig[panelKey] || {}; + const components = panelConfig.components || []; + const targetIdx = components.findIndex((c: any) => c.id === componentId); + if (targetIdx === -1) return prevLayout; + const targetComp = components[targetIdx]; + const updatedComp = + path === "style" + ? { ...targetComp, style: value } + : setNestedValue(targetComp, path, value); + const updatedComponents = [ + ...components.slice(0, targetIdx), + updatedComp, + ...components.slice(targetIdx + 1), + ]; + const updatedSplitPanel = { + ...splitPanelComponent, + componentConfig: { + ...currentConfig, + [panelKey]: { ...panelConfig, components: updatedComponents }, + }, + }; + setSelectedPanelComponentInfo((prev) => + prev ? { ...prev, component: updatedComp } : null, + ); + const newLayout = { + ...prevLayout, + components: prevLayout.components.map((c: any) => + c.id === splitPanelId ? updatedSplitPanel : c, + ), + }; + saveToHistory(newLayout); + return newLayout; + }); + }; + + const deletePanelChild = (componentId: string) => { + setLayout((prevLayout) => { + const splitPanelComponent = prevLayout.components.find( + (c: any) => c.id === splitPanelId, + ); + if (!splitPanelComponent) return prevLayout; + const currentConfig = (splitPanelComponent as any).componentConfig || {}; + const panelConfig = currentConfig[panelKey] || {}; + const components = panelConfig.components || []; + const updatedSplitPanel = { + ...splitPanelComponent, + componentConfig: { + ...currentConfig, + [panelKey]: { + ...panelConfig, + components: components.filter((c: any) => c.id !== componentId), + }, + }, + }; + setSelectedPanelComponentInfo(null); + const newLayout = { + ...prevLayout, + components: prevLayout.components.map((c: any) => + c.id === splitPanelId ? updatedSplitPanel : c, + ), + }; + saveToHistory(newLayout); + return newLayout; + }); + }; + + return ( + 0 ? (tables[0] as any) : undefined} + currentTableName={selectedScreen?.table_name} + currentScreenCompanyCode={selectedScreen?.company_code} + onStyleChange={(style) => { + updatePanelChildProperty(panelComp.id, "style", style); + }} + allComponents={layout.components} + menuObjid={menuObjid} + /> + ); + })() + ) : selectedComponent ? ( - {/* ScreenDesigner 전체 화면 - Dialog 중첩 시 오버레이 컴포지팅으로 깜빡임 발생하여 직접 렌더링 */} - {/* ScreenDesigner 자체 SlimToolbar에 "목록으로" 닫기 버튼이 있으므로 별도 X 버튼 불필요 */} + {/* InvyoneStudio 전체 화면 - Dialog 중첩 시 오버레이 컴포지팅으로 깜빡임 발생하여 직접 렌더링 */} + {/* InvyoneStudio 자체 SlimToolbar에 "목록으로" 닫기 버튼이 있으므로 별도 X 버튼 불필요 */} {showDesignerModal && (
- = ({ {/* 추가 데이터 제공 가능한 컴포넌트 (조건부 컨테이너, 셀렉트박스 등) */} {allComponents .filter((comp: any) => { - const type = comp.componentType || comp.type || ""; + const type = comp.componentType || comp.component_type || comp.type || ""; + const cfg = comp.componentConfig || comp.component_config || {}; + const isCanonicalChoice = + type === "input" && + (cfg.kind === "choice" || + cfg.type === "single" || + cfg.type === "multi" || + ["list", "code", "entity", "tags", "status", "boolean"].includes(cfg.format) || + ["static", "select", "category", "code", "entity", "distinct", "db", "api"].includes(cfg.source)); // 소스/타겟과 다른 컴포넌트 중 값을 제공할 수 있는 타입 - return ["conditional-container", "select", "combobox"].some((t) => - type.includes(t), + return ( + isCanonicalChoice || + ["conditional-container", "select", "combobox"].some((t) => type.includes(t)) ); }) .map((comp: any) => { - const compType = comp.componentType || comp.type || "unknown"; - const compLabel = comp.label || comp.componentConfig?.controlLabel || comp.id; + const compType = comp.componentType || comp.component_type || comp.type || "unknown"; + const cfg = comp.componentConfig || comp.component_config || {}; + const compLabel = comp.label || cfg.controlLabel || cfg.label || comp.id; return (
diff --git a/frontend/components/screen/config-panels/button/DataTab.tsx b/frontend/components/screen/config-panels/button/DataTab.tsx index 44ab13a5..55bf0732 100644 --- a/frontend/components/screen/config-panels/button/DataTab.tsx +++ b/frontend/components/screen/config-panels/button/DataTab.tsx @@ -386,14 +386,24 @@ export const DataTab: React.FC = ({ {allComponents .filter((comp: any) => { - const type = comp.componentType || comp.type || ""; - return ["conditional-container", "select", "combobox"].some((t) => - type.includes(t), + const type = comp.componentType || comp.component_type || comp.type || ""; + const cfg = comp.componentConfig || comp.component_config || {}; + const isCanonicalChoice = + type === "input" && + (cfg.kind === "choice" || + cfg.type === "single" || + cfg.type === "multi" || + ["list", "code", "entity", "tags", "status", "boolean"].includes(cfg.format) || + ["static", "select", "category", "code", "entity", "distinct", "db", "api"].includes(cfg.source)); + return ( + isCanonicalChoice || + ["conditional-container", "select", "combobox"].some((t) => type.includes(t)) ); }) .map((comp: any) => { - const compType = comp.componentType || comp.type || "unknown"; - const compLabel = comp.label || comp.componentConfig?.controlLabel || comp.id; + const compType = comp.componentType || comp.component_type || comp.type || "unknown"; + const cfg = comp.componentConfig || comp.component_config || {}; + const compLabel = comp.label || cfg.controlLabel || cfg.label || comp.id; return (
diff --git a/frontend/components/screen/panels/ComponentsPanel.tsx b/frontend/components/screen/panels/ComponentsPanel.tsx index 36e954d6..e9e43139 100644 --- a/frontend/components/screen/panels/ComponentsPanel.tsx +++ b/frontend/components/screen/panels/ComponentsPanel.tsx @@ -333,7 +333,8 @@ export function ComponentsPanel({ // ── 기본/고급 ID 분류 ── const BASIC_IDS = new Set([ - "table", "search", "input", "button", "stats", "title", "divider", "container", + "table", "search", "input", "button", "stats", "chart", "card-list", "grouped-table", + "title", "divider", "container", "v2-repeater", ]); const ADVANCED_IDS = new Set([ diff --git a/frontend/components/screen/panels/V2PropertiesPanel.tsx b/frontend/components/screen/panels/V2PropertiesPanel.tsx index 0abb33a8..f51f5120 100644 --- a/frontend/components/screen/panels/V2PropertiesPanel.tsx +++ b/frontend/components/screen/panels/V2PropertiesPanel.tsx @@ -402,10 +402,6 @@ export const V2PropertiesPanel: React.FC = ({ // 새로운 통합 입력 컴포넌트 (옛 V2 입력/선택은 input canonical 로 흡수되어 목록에서 제거됨) "input", "v2-entity-select", - "v2-checkbox", - "v2-radio", - "v2-textarea", - "v2-file", ]; // 현재 컴포넌트가 입력 필드인지 확인 diff --git a/frontend/components/v2/config-panels/InvFieldConfigPanel.tsx b/frontend/components/v2/config-panels/InvFieldConfigPanel.tsx index 1f76ed61..0a4c55b4 100644 --- a/frontend/components/v2/config-panels/InvFieldConfigPanel.tsx +++ b/frontend/components/v2/config-panels/InvFieldConfigPanel.tsx @@ -4,10 +4,10 @@ * V2 통합 필드 설정 패널 (INVYONE Studio 컨셉 적용) * * 분류 체계 (시안 panel-input-new 컨셉) - * kind = 입력 / 선택 (type 의 group 으로 자동 결정) + * kind = 입력 / 선택·태그 (type 의 group 으로 자동 결정) * type * 입력 : 글자(text) · 숫자(number) · 여러 줄(textarea) · 채번(numbering) - * 선택 : 직접 목록(select) · 카테고리(category) · 테이블 참조(entity) + * 선택·태그 : 단일 선택(single) · 다중값(multi: 목록/코드/엔티티/태그) * format = text 안에서만 자유/이메일/전화/URL/사업자번호/통화 * * 화면 구조 @@ -57,13 +57,13 @@ import type { CPCrumbType, CPFormatItem } from "./_shared/cp"; type Kind = "input" | "choice" | "auto" | "attach"; type Type = | "text" | "number" | "money" | "date" // 입력 - | "single" | "multi" // 선택 + | "single" | "multi" // 선택/태그 | "autonum" | "formula" | "audit" // 자동 | "file"; // 첨부 const KINDS: { id: Kind; name: string; icon: React.ReactNode }[] = [ { id: "input", name: "입력", icon: }, - { id: "choice", name: "선택", icon: }, + { id: "choice", name: "선택·태그", icon: }, { id: "auto", name: "자동", icon: }, { id: "attach", name: "첨부", icon: 📎 }, ]; @@ -76,8 +76,8 @@ const TYPES_BY_KIND: Record = { { id: "date", name: "날짜", desc: "날짜·시간", icon: "📅", col: "TIMESTAMP" }, ], choice: [ - { id: "single", name: "단일", desc: "하나만 선택", icon: "◉", col: "VARCHAR" }, - { id: "multi", name: "다중", desc: "여러 개 선택", icon: "☷", col: "JSON" }, + { id: "single", name: "단일 선택", desc: "목록에서 하나", icon: "◉", col: "VARCHAR" }, + { id: "multi", name: "다중값", desc: "태그·여러 항목", icon: "☷", col: "JSON" }, ], auto: [ { id: "autonum", name: "채번", desc: "자동 일련번호", icon: "№", col: "VARCHAR" }, @@ -118,17 +118,17 @@ const FORMATS_BY_TYPE: Record = { { id: "range", name: "기간", desc: "시작 ~ 끝", icon: "↔" }, ], single: [ - { id: "list", name: "고정 목록", desc: "여기서 정의", icon: "▾" }, + { id: "list", name: "고정 목록", desc: "여기서 정의", icon: "LS" }, { id: "code", name: "공통코드", desc: "코드 테이블", icon: "CD" }, { id: "entity", name: "엔티티", desc: "다른 테이블 (FK)", icon: "FK" }, { id: "status", name: "상태", desc: "색·라벨", icon: "●" }, { id: "boolean", name: "예/아니오", desc: "둘 중 하나", icon: "◐" }, ], multi: [ - { id: "tags", name: "태그", desc: "자유 다중", icon: "#" }, - { id: "list", name: "고정 목록", desc: "체크박스", icon: "☑" }, - { id: "code", name: "공통코드", desc: "여러 코드", icon: "CD" }, - { id: "entity", name: "엔티티", desc: "다대다", icon: "FK" }, + { id: "tags", name: "태그 입력", desc: "직접 입력", icon: "TG" }, + { id: "list", name: "고정 목록", desc: "여러 항목 선택", icon: "CB" }, + { id: "code", name: "공통코드", desc: "여러 코드 선택", icon: "CD" }, + { id: "entity", name: "엔티티", desc: "여러 참조 선택", icon: "FK" }, ], autonum: [{ id: "autonum", name: "채번", desc: "ORD-2025-####", icon: "№" }], formula: [{ id: "formula", name: "계산", desc: "qty * price", icon: "ƒx" }], @@ -247,7 +247,7 @@ function resolveTriple( } // 2. 컴포넌트 ID 가 명시적으로 별도면 그쪽으로 - if (componentType === "v2-file" || config.fieldType === "file") { + if (config.fieldType === "file") { const a = config.accept || ""; if (a.startsWith("image/")) return { kind: "attach", type: "file", format: "image" }; if (a.includes("pdf") || a.includes("docx") || a.includes(".doc")) @@ -323,6 +323,7 @@ const TYPE_VOLATILE_FIELDS = [ "fieldType", "inputType", "source", "multiple", "step", "min", "max", "rows", "tags", "accept", "unit", "thousands", "mode", "mask", + "boolStyle", "trueLabel", "falseLabel", "autoGeneration", "computed", "readonly", // type 별 옵션 (select options / text length / date 옵션) "options", "minLength", "maxLength", @@ -405,6 +406,8 @@ function applyTriple( } else if (format === "boolean") { next.fieldType = "checkbox"; next.multiple = false; + next.mode = "toggle"; + next.boolStyle = "switch"; } return next; } @@ -1704,7 +1707,7 @@ function BooleanOptions({ }) { return ( <> - 실제 동작은 v2-checkbox / boolean 컴포넌트로 위임. 라벨/표시 모드만 여기서. + canonical input 이 직접 처리합니다. 표시 형태와 라벨만 설정합니다. - 실제 업로드 동작은 v2-file 컴포넌트로 위임. 정책/제한만 여기서. + canonical input 의 파일 필드로 업로드합니다. 허용 형식과 제한만 설정합니다. void; multi?: boolean; }) { + const isTagsFormat = multi && config.format === "tags"; + return ( <> - - updateConfig("mode", v)}> - - - {!multi && } - {multi && } - {multi && } - {multi && } - {!multi && } - - + {!isTagsFormat && ( + + updateConfig("mode", v)}> + + + {!multi && } + {multi && } + {multi && } + {!multi && } + + + )} {multi && ( - + updateConfig("maxSelect", v)} @@ -2059,12 +2065,16 @@ function SelectAdvancedOptions({ /> )} - - updateConfig("searchable", v)} /> - - - updateConfig("allowClear", v)} /> - + {!isTagsFormat && ( + <> + + updateConfig("searchable", v)} /> + + + updateConfig("allowClear", v)} /> + + + )} ); } diff --git a/frontend/lib/api/stats.ts b/frontend/lib/api/stats.ts new file mode 100644 index 00000000..de071a28 --- /dev/null +++ b/frontend/lib/api/stats.ts @@ -0,0 +1,160 @@ +import { apiClient } from "./client"; +import type { OptionFilter } from "@/lib/registry/components/input/use-option-loader"; + +export type StatsAggregation = + | "count" + | "sum" + | "avg" + | "min" + | "max" + | "distinctCount"; + +/** + * AggregateRequest — 백엔드 `/api/table-management/tables/{tableName}/aggregate` body. + * + * filters 는 `OptionFilter[]` 와 동일한 모양으로 보낸다. 단, `value_type` / `field_ref` / + * `user_field` 는 호출자가 미리 실제 값으로 치환해서 `value` 만 남긴 상태여야 한다 — 백엔드는 + * 그 외 필드를 무시한다. + */ +export interface AggregateRequest { + aggregation: StatsAggregation; + columnName?: string; + filters?: Array>; +} + +export interface AggregateResponse { + value: number; +} + +/** + * aggregateTableStat — 단일 stat 카드 값을 백엔드에서 계산해 가져온다. + * + * 디자인 모드 / 빈 tableName 가드는 호출자가 책임진다. + * 실패 시 axios error 가 그대로 throw 됨 — 상위 hook 에서 카드 단위 fallback. + */ +export async function aggregateTableStat( + tableName: string, + request: AggregateRequest, +): Promise { + const res = await apiClient.post( + `/table-management/tables/${encodeURIComponent(tableName)}/aggregate`, + request, + ); + const body = res.data; + // ApiResponse<{ value: number }> wrapper. value 는 number 또는 numeric string 가능. + const payload = (body?.data ?? body) as { value?: unknown }; + const raw = payload?.value; + const value = + typeof raw === "number" + ? raw + : typeof raw === "string" && raw.trim() !== "" + ? Number(raw) + : 0; + return { value: Number.isFinite(value) ? value : 0 }; +} + +/** + * AggregateGroupRequest — Phase G.3 canonical chart 컴포넌트가 사용하는 + * `/aggregate-group` body. + * + * 백엔드가 `GROUP BY ` 후 각 그룹마다 단일 집계 값을 계산해 row 배열로 반환. + * `valueColumn` 는 `aggregation === "count"` 일 때 생략 가능. distinctCount / sum / + * avg / min / max 는 valueColumn 필수 (백엔드 측 가드). + */ +export interface AggregateGroupRequest { + aggregation: StatsAggregation; + groupBy: string; + valueColumn?: string; + filters?: Array>; + limit?: number; + orderDir?: "asc" | "desc"; +} + +export interface AggregateGroupRow { + /** GROUP BY 컬럼의 raw 값. null 가능. */ + group: string | number | null; + /** 집계 결과 (숫자). */ + value: number; +} + +export interface AggregateGroupResponse { + rows: AggregateGroupRow[]; +} + +/** + * aggregateTableGroup — 그룹별 집계. + * + * 호출자는 디자인 모드 / 빈 tableName / 빈 groupBy 가드를 책임진다. 실패는 axios + * error 로 throw 된다. + */ +export async function aggregateTableGroup( + tableName: string, + request: AggregateGroupRequest, +): Promise { + const res = await apiClient.post( + `/table-management/tables/${encodeURIComponent(tableName)}/aggregate-group`, + request, + ); + const body = res.data; + const payload = (body?.data ?? body) as { rows?: unknown }; + const rawRows = Array.isArray(payload?.rows) ? (payload!.rows as any[]) : []; + const rows: AggregateGroupRow[] = rawRows.map((r) => { + const v = r?.value; + const num = + typeof v === "number" + ? v + : typeof v === "string" && v.trim() !== "" + ? Number(v) + : 0; + return { + group: r?.group ?? null, + value: Number.isFinite(num) ? num : 0, + }; + }); + return { rows }; +} + +/** + * SelectRowsRequest — Phase G.3.1 canonical cardList / groupedTable 가 사용하는 + * `/select-rows` body. + * + * 단순 SELECT — 필터 / 정렬 / limit 만 적용해서 raw row 들을 받는다. column 단일 + * 집계가 아니라 multi-column row 가 필요한 view 컴포넌트용. + */ +export interface SelectRowsOrderBy { + column: string; + direction?: "asc" | "desc"; +} + +export interface SelectRowsRequest { + columns?: string[]; + filters?: Array>; + orderBy?: SelectRowsOrderBy[]; + limit?: number; + offset?: number; +} + +export interface SelectRowsResponse { + rows: Record[]; +} + +/** + * selectTableRows — multi-column row 들을 받아오는 가벼운 SELECT. + * + * 호출자는 디자인 모드 / 빈 tableName 가드를 책임진다. 실패는 axios error 로 throw. + */ +export async function selectTableRows( + tableName: string, + request: SelectRowsRequest, +): Promise { + const res = await apiClient.post( + `/table-management/tables/${encodeURIComponent(tableName)}/select-rows`, + request, + ); + const body = res.data; + const payload = (body?.data ?? body) as { rows?: unknown }; + const rawRows = Array.isArray(payload?.rows) + ? (payload!.rows as Record[]) + : []; + return { rows: rawRows }; +} diff --git a/frontend/lib/registry/DynamicWebTypeRenderer.tsx b/frontend/lib/registry/DynamicWebTypeRenderer.tsx index c816bd13..89545118 100644 --- a/frontend/lib/registry/DynamicWebTypeRenderer.tsx +++ b/frontend/lib/registry/DynamicWebTypeRenderer.tsx @@ -6,6 +6,7 @@ import { DynamicComponentProps } from "./types"; // import { getWidgetComponentByWebType, getWidgetComponentByName } from "@/components/screen/widgets/types"; // 임시 비활성화 import { useWebTypes } from "@/hooks/admin/useWebTypes"; import { filterDOMProps } from "@/lib/utils/domPropsFilter"; +import { getV2MappingFromWebType } from "@/lib/utils/webTypeMapping"; type DynamicWebTypeRendererProps = Omit & { web_type?: string; @@ -13,6 +14,37 @@ type DynamicWebTypeRendererProps = Omit; }; +const CANONICAL_INPUT_WEB_TYPES = new Set([ + "text", + "email", + "password", + "tel", + "url", + "color", + "number", + "decimal", + "slider", + "date", + "datetime", + "time", + "daterange", + "select", + "dropdown", + "radio", + "checkbox", + "boolean", + "textarea", + "text_area", + "code", + "category", + "entity", + "file", + "image", + "img", + "picture", + "photo", +]); + /** * 동적 웹타입 렌더러 컴포넌트 * 레지스트리에서 웹타입을 조회하여 동적으로 렌더링합니다. @@ -77,36 +109,48 @@ export const DynamicWebTypeRenderer: React.FC = ({ }; }, [props, mergedConfig, webType, onEvent]); - // file / image / img / picture / photo — canonical `input` 으로 라우팅 (Phase D.4). - // FileUploadComponent / ImageWidget 의 직접 import 는 끊고, InputComponent 가 자체 - // FilePicker 로 처리. 옛 컴포넌트 파일 자체 삭제는 사용처 0건 확인 후 별도 phase. - if ( - webType === "file" || - webType === "image" || - webType === "img" || - webType === "picture" || - webType === "photo" || - props.component?.type === "file" - ) { + // 입력 webType 은 WebTypeRegistry 의 old widget 보다 먼저 canonical `input` 으로 라우팅. + // entity 도 검색 모달이 아니라 code-name option source 로 처리한다. + if (CANONICAL_INPUT_WEB_TYPES.has(webType) || props.component?.type === "file") { try { const { InputComponent } = require("@/lib/registry/components/input/InputComponent"); const isImage = webType === "image" || webType === "img" || webType === "picture" || webType === "photo"; - const fileConfig = { - type: "file", - format: isImage ? "image" : "file", - accept: isImage ? "image/*" : "*/*", - multiple: !isImage, - showPreview: isImage, - }; + const mappedWebType = + webType === "picture" || webType === "photo" ? "image" : webType === "text_area" ? "textarea" : webType; + const mapped = getV2MappingFromWebType(mappedWebType); + const canonicalConfig = + mapped.componentType === "input" + ? mapped.config + : { kind: "input", type: "text", format: "free" }; + const fileConfig = isImage + ? { + kind: "attach", + type: "file", + format: "image", + accept: "image/*", + multiple: false, + maxFiles: 1, + showPreview: true, + } + : props.component?.type === "file" + ? { + kind: "attach", + type: "file", + format: "file", + accept: "*/*", + multiple: true, + maxFiles: 10, + } + : {}; return ( ); } catch (error) { - console.error("canonical input (file) 로드 실패:", error); + console.error("canonical input 로드 실패:", error); } } diff --git a/frontend/lib/registry/components/_shared/ColumnPicker.tsx b/frontend/lib/registry/components/_shared/ColumnPicker.tsx new file mode 100644 index 00000000..0855113b --- /dev/null +++ b/frontend/lib/registry/components/_shared/ColumnPicker.tsx @@ -0,0 +1,81 @@ +"use client"; + +/** + * ColumnPicker — 테이블의 컬럼 dropdown (cp 톤, CPSelect 기반). + * + * tableName 이 있으면 useDbColumns 로 컬럼 목록 자동 로드. 현재 값이 목록에 없으면 + * "사용자 정의" option 으로 보존. tableName 미설정 시 free-text input 으로 폴백. + * + * 사용처: stats / chart / cardList / groupedTable 의 컬럼 선택 (집계 컬럼, + * 그룹 컬럼, 정렬 컬럼, 필드 매핑, 필터 컬럼 등). + */ + +import React from "react"; +import { CPSelect, CPText } from "@/components/v2/config-panels/_shared/cp"; +import { + useDbColumns, + type DbColumnOption, +} from "@/lib/registry/components/common/useDbColumns"; + +export interface ColumnPickerProps { + tableName?: string; + value?: string; + onChange: (v: string | undefined) => void; + placeholder?: string; + /** 외부에서 미리 받은 컬럼 (tableName 없을 때 fallback 옵션) */ + columnOptions?: DbColumnOption[]; + /** 필터 옵션 — 특정 data_type 만 허용 (예: numeric 만) */ + filter?: (col: DbColumnOption) => boolean; +} + +export const ColumnPicker: React.FC = ({ + tableName, + value, + onChange, + placeholder = "컬럼 선택…", + columnOptions, + filter, +}) => { + const fromHook = useDbColumns(tableName); + const cols: DbColumnOption[] = React.useMemo(() => { + const base = + columnOptions && columnOptions.length > 0 + ? columnOptions + : fromHook.columns; + return filter ? base.filter(filter) : base; + }, [columnOptions, fromHook.columns, filter]); + const loading = fromHook.loading && cols.length === 0; + const hasCols = cols.length > 0; + const currentValid = !value || cols.some((c) => c.value === value); + + if (!hasCols) { + // 테이블 미설정 / 컬럼 없음 — free-text 폴백 + return ( + onChange(v || undefined)} + placeholder={loading ? "컬럼 로딩 중…" : tableName ? "컬럼 없음" : placeholder} + /> + ); + } + + return ( + onChange(v || undefined)} + placeholder={loading ? "로딩…" : placeholder} + > + + {!currentValid && value && ( + + )} + {cols.map((c) => ( + + ))} + + ); +}; diff --git a/frontend/lib/registry/components/_shared/FilterRow.tsx b/frontend/lib/registry/components/_shared/FilterRow.tsx new file mode 100644 index 00000000..a27770ac --- /dev/null +++ b/frontend/lib/registry/components/_shared/FilterRow.tsx @@ -0,0 +1,260 @@ +"use client"; + +import React from "react"; +import { Trash2 } from "lucide-react"; +import { CPSelect, CPText } from "@/components/v2/config-panels/_shared/cp"; +import type { OptionFilter } from "@/lib/registry/components/input/use-option-loader"; +import { ColumnPicker } from "./ColumnPicker"; +import type { DbColumnOption } from "@/lib/registry/components/common/useDbColumns"; + +// 추가 시 fade-in keyframe (한 번만 inject) +const FILTER_ROW_KEYFRAMES = ` +@keyframes filter-row-fade-in { + from { opacity: 0; transform: translateY(-2px); } + to { opacity: 1; transform: translateY(0); } +}`; + +function useFilterRowKeyframesOnce() { + React.useEffect(() => { + const id = "filter-row-keyframes"; + if (typeof document === "undefined" || document.getElementById(id)) return; + const style = document.createElement("style"); + style.id = id; + style.textContent = FILTER_ROW_KEYFRAMES; + document.head.appendChild(style); + }, []); +} + +/** + * 공용 FilterRow — Phase G.4 / G.5: 자연어 카드 형식으로 재설계. + * + * ┌──────────────────────────────────────────── 🗑 ─┐ + * │ 컬럼 [상태 (status) ▾] │ + * │ 조건 [= ▾] 값 종류 [고정값 ▾] │ + * │ 값 [재직 ] │ + * └──────────────────────────────────────────────┘ + * + * 각 row 가 CPSelect / CPText 로 cp 톤 일관. wrap 으로 좁아도 깔끔. + * 사용처: stats / chart / cardList / groupedTable 의 OptionFilter 빌더. + */ + +const FILTER_OP_LABELS: Array<{ value: OptionFilter["operator"]; label: string }> = [ + { value: "=", label: "같음 (=)" }, + { value: "!=", label: "같지 않음 (≠)" }, + { value: ">", label: "크다 (>)" }, + { value: "<", label: "작다 (<)" }, + { value: ">=", label: "크거나 같다 (≥)" }, + { value: "<=", label: "작거나 같다 (≤)" }, + { value: "like", label: "포함 (LIKE)" }, + { value: "in", label: "여러 값 중 하나 (IN)" }, + { value: "notIn", label: "여러 값 제외 (NOT IN)" }, + { value: "isNull", label: "값이 없음 (NULL)" }, + { value: "isNotNull", label: "값이 있음 (NOT NULL)" }, +]; + +const VALUE_TYPE_LABELS: Array<{ value: NonNullable; label: string }> = [ + { value: "static", label: "고정값" }, + { value: "field", label: "폼 필드" }, + { value: "user", label: "사용자 컨텍스트" }, +]; + +const USER_FIELD_LABELS: Array<{ value: NonNullable; label: string }> = [ + { value: "companyCode", label: "회사 코드" }, + { value: "userId", label: "사용자 ID" }, + { value: "deptCode", label: "부서 코드" }, + { value: "userName", label: "사용자명" }, +]; + +export interface FilterRowProps { + filter: OptionFilter; + onChange: (p: Partial) => void; + onRemove: () => void; + /** 테이블 이름 — 있으면 컬럼 select 자동 채움 */ + tableName?: string; + /** 외부에서 미리 받은 컬럼 (tableName 없을 때 fallback) */ + columnOptions?: DbColumnOption[]; +} + +export const OptionFilterRow: React.FC = ({ + filter, + onChange, + onRemove, + tableName, + columnOptions, +}) => { + useFilterRowKeyframesOnce(); + const valueType = filter.value_type ?? "static"; + const op = filter.operator ?? "="; + const hideValue = op === "isNull" || op === "isNotNull"; + + return ( +
+ {/* 삭제 버튼 — 우측 상단 */} + + + {/* row 1: 컬럼 */} + + onChange({ column: v ?? "" })} + placeholder="컬럼 선택…" + /> + + + {/* row 2: 조건 + (필요시) 값 종류 */} + +
+
+ onChange({ operator: v as OptionFilter["operator"] })} + > + {FILTER_OP_LABELS.map((o) => ( + + ))} + +
+ {!hideValue && ( +
+ + onChange({ + value_type: v as OptionFilter["value_type"], + value: v === "static" ? filter.value : undefined, + field_ref: v === "field" ? filter.field_ref : undefined, + user_field: v === "user" ? filter.user_field : undefined, + }) + } + > + {VALUE_TYPE_LABELS.map((o) => ( + + ))} + +
+ )} +
+
+ + {/* row 3: 값 (hideValue 면 안 보임) */} + {!hideValue && ( + + {valueType === "static" ? ( + onChange({ value: v })} + placeholder={ + op === "in" || op === "notIn" + ? "쉼표로 구분 — 예: 재직, 휴직, 퇴사" + : "고정값 입력 — 예: 재직" + } + /> + ) : valueType === "field" ? ( + onChange({ field_ref: v })} + placeholder="폼 필드명 — 화면의 어떤 입력값을 따라갈지" + /> + ) : ( + onChange({ user_field: v as OptionFilter["user_field"] })} + > + {USER_FIELD_LABELS.map((o) => ( + + ))} + + )} + + )} +
+ ); +}; + +// ─────────────────────────────────────────────────────── +// FieldRow — 라벨 + 값 한 줄 (cp Row 톤, label 폭 고정) +// ─────────────────────────────────────────────────────── +function FieldRow({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( +
+ + {label} + +
{children}
+
+ ); +} diff --git a/frontend/lib/registry/components/_shared/use-table-rows.ts b/frontend/lib/registry/components/_shared/use-table-rows.ts new file mode 100644 index 00000000..83987a7a --- /dev/null +++ b/frontend/lib/registry/components/_shared/use-table-rows.ts @@ -0,0 +1,211 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { + selectTableRows, + type SelectRowsOrderBy, + type SelectRowsResponse, +} from "@/lib/api/stats"; +import type { OptionFilter } from "@/lib/registry/components/input/use-option-loader"; + +/** + * useTableRows — Phase G.3.1 view 컴포넌트 (card-list / grouped-table) 공통 fetch hook. + * + * - 디자인 모드: API 호출 0. preview row 반환 + * - dataSource.tableName 없으면 preview + * - request key 캐시 + * - version counter race guard + * - 카드/탭 단위 error 격리 + * - `OptionFilter.value_type` 의 field / user 는 formData / userContext 로 치환 + */ + +export interface TableRowsDataSource { + tableName?: string; + columns?: string[]; + filters?: OptionFilter[]; + orderBy?: SelectRowsOrderBy[]; + limit?: number; +} + +export interface UseTableRowsOptions { + isDesignMode?: boolean; + formData?: Record; + userContext?: { + companyCode?: string; + userId?: string; + deptCode?: string; + userName?: string; + }; + refreshKey?: number | string; + /** preview 모드의 가짜 row 개수 (기본 4) */ + previewCount?: number; + /** preview row 생성기 — 컴포넌트별 맞춤 가능 */ + previewBuilder?: (index: number) => Record; +} + +export interface TableRowsState { + rows: Record[]; + loading: boolean; + error?: string; + /** dataSource 미완성으로 preview 만 보여주는 중인지 */ + isPreview: boolean; +} + +function defaultPreview(count: number): Record[] { + const out: Record[] = []; + for (let i = 0; i < count; i++) { + out.push({ + __preview: true, + __index: i, + title: `샘플 ${i + 1}`, + subtitle: `미리보기 항목 ${i + 1}`, + value: (i + 1) * 7, + }); + } + return out; +} + +function resolveFilters( + filters: OptionFilter[] | undefined, + formData: Record | undefined, + userContext: UseTableRowsOptions["userContext"], +): { resolved: Array<{ column: string; operator: string; value?: unknown }>; ok: boolean } { + if (!filters || filters.length === 0) return { resolved: [], ok: true }; + const out: Array<{ column: string; operator: string; value?: unknown }> = []; + for (const f of filters) { + if (!f || !f.column) continue; + const op = f.operator ?? "="; + if (op === "isNull" || op === "isNotNull") { + out.push({ column: f.column, operator: op }); + continue; + } + let value: unknown = f.value; + const valueType = f.value_type ?? "static"; + if (valueType === "field") { + const ref = f.field_ref; + if (!ref) return { resolved: [], ok: false }; + value = formData?.[ref]; + if (value === undefined) return { resolved: [], ok: false }; + } else if (valueType === "user") { + const uf = f.user_field; + if (!uf) return { resolved: [], ok: false }; + value = userContext?.[uf]; + if (value === undefined || value === "") return { resolved: [], ok: false }; + } + if (value === undefined || value === null) continue; + out.push({ column: f.column, operator: op, value }); + } + return { resolved: out, ok: true }; +} + +function buildRequestKey( + ds: TableRowsDataSource, + filters: Array<{ column: string; operator: string; value?: unknown }>, +): string { + return JSON.stringify({ + t: ds.tableName, + c: ds.columns ?? null, + o: ds.orderBy ?? null, + l: ds.limit ?? 50, + f: filters, + }); +} + +function isReady(ds: TableRowsDataSource | undefined): boolean { + return !!(ds && ds.tableName); +} + +export function useTableRows( + dataSource: TableRowsDataSource | undefined, + opts: UseTableRowsOptions = {}, +): TableRowsState { + const { isDesignMode, formData, userContext, refreshKey, previewCount, previewBuilder } = opts; + + const previewRows = useMemo[]>(() => { + const n = Math.max(2, Math.min(12, previewCount ?? 4)); + if (previewBuilder) { + const out: Record[] = []; + for (let i = 0; i < n; i++) out.push(previewBuilder(i)); + return out; + } + return defaultPreview(n); + }, [previewCount, previewBuilder]); + + const [state, setState] = useState({ + rows: previewRows, + loading: false, + isPreview: true, + }); + + const plan = useMemo(() => { + if (!isReady(dataSource)) return { skip: true as const }; + const { resolved, ok } = resolveFilters(dataSource!.filters, formData, userContext); + if (!ok) return { skip: true as const, pending: true as const }; + const reqKey = buildRequestKey(dataSource!, resolved); + return { + skip: false as const, + reqKey, + req: { + tableName: dataSource!.tableName!, + columns: dataSource!.columns, + filters: resolved, + orderBy: dataSource!.orderBy, + limit: dataSource!.limit ?? 50, + }, + }; + }, [dataSource, formData, userContext]); + + const cacheRef = useRef[]>>(new Map()); + const versionRef = useRef(0); + + useEffect(() => { + if (isDesignMode) { + cacheRef.current.clear(); + setState({ rows: previewRows, loading: false, isPreview: true }); + return; + } + if (plan.skip) { + setState({ rows: previewRows, loading: false, isPreview: true }); + return; + } + + versionRef.current += 1; + const myVersion = versionRef.current; + let cancelled = false; + + if (refreshKey !== undefined) cacheRef.current.clear(); + + const cached = cacheRef.current.get(plan.reqKey!); + if (cached !== undefined && refreshKey === undefined) { + setState({ rows: cached, loading: false, isPreview: false }); + return; + } + + setState((prev) => ({ ...prev, loading: true, error: undefined })); + + (async () => { + try { + const res: SelectRowsResponse = await selectTableRows(plan.req!.tableName, { + columns: plan.req!.columns, + filters: plan.req!.filters as any, + orderBy: plan.req!.orderBy, + limit: plan.req!.limit, + }); + if (cancelled || versionRef.current !== myVersion) return; + cacheRef.current.set(plan.reqKey!, res.rows); + setState({ rows: res.rows, loading: false, isPreview: false }); + } catch (err: any) { + if (cancelled || versionRef.current !== myVersion) return; + const msg = err?.response?.data?.message || err?.message || "데이터 로드 실패"; + setState({ rows: [], loading: false, isPreview: false, error: String(msg) }); + } + })(); + + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [plan, isDesignMode, refreshKey, previewRows]); + + return state; +} diff --git a/frontend/lib/registry/components/card-list/CardListComponent.tsx b/frontend/lib/registry/components/card-list/CardListComponent.tsx new file mode 100644 index 00000000..ef5b2f30 --- /dev/null +++ b/frontend/lib/registry/components/card-list/CardListComponent.tsx @@ -0,0 +1,346 @@ +"use client"; + +import React from "react"; +import type { ComponentRendererProps } from "@/types/component"; +import { filterDOMProps } from "@/lib/utils/domPropsFilter"; +import { useTableRows } from "../_shared/use-table-rows"; +import type { CardListConfig, CardListLayout, CardListMetric } from "./types"; + +/** + * CardList — canonical 카드 리스트 컴포넌트 (Phase G.3.1) + * + * 테이블 row 들을 반복 카드로 출력. titleField / subtitleFields / metricFields 를 + * 자유 조합. 디자인 모드 / dataSource 미완성: 정적 preview 4 장. + */ + +const VALID_LAYOUTS: CardListLayout[] = ["list", "grid"]; + +export interface CardListComponentProps extends ComponentRendererProps { + config?: CardListConfig; +} + +const cellToText = (v: unknown): string => { + if (v === null || v === undefined || v === "") return "—"; + if (typeof v === "number") return v.toLocaleString(); + if (typeof v === "boolean") return v ? "✓" : "✗"; + if (v instanceof Date) return v.toLocaleString(); + return String(v); +}; + +export const CardListComponent: React.FC = ({ + component, + isDesignMode = false, + isSelected = false, + onClick, + onDragStart, + onDragEnd, + config, + className, + style, + ...props +}) => { + const p = props as any; + const fromProps: Partial = {}; + if (typeof p.title === "string") fromProps.title = p.title; + if (typeof p.layout === "string" && (VALID_LAYOUTS as string[]).includes(p.layout)) + fromProps.layout = p.layout as CardListLayout; + if (typeof p.columns === "number") fromProps.columns = p.columns; + if (p.dataSource && typeof p.dataSource === "object") fromProps.dataSource = p.dataSource; + if (typeof p.titleField === "string") fromProps.titleField = p.titleField; + if (Array.isArray(p.subtitleFields)) fromProps.subtitleFields = p.subtitleFields; + if (Array.isArray(p.metricFields)) fromProps.metricFields = p.metricFields; + if (typeof p.emptyText === "string") fromProps.emptyText = p.emptyText; + + const componentConfig = { + ...config, + ...((component as any).config ?? {}), + ...((component as any).componentConfig ?? {}), + ...fromProps, + } as CardListConfig; + + const layout: CardListLayout = (VALID_LAYOUTS as string[]).includes( + componentConfig.layout as string, + ) + ? (componentConfig.layout as CardListLayout) + : "list"; + const columns = componentConfig.columns; + const title = componentConfig.title; + const titleField = componentConfig.titleField; + const subtitleFields = componentConfig.subtitleFields ?? []; + const metricFields: CardListMetric[] = componentConfig.metricFields ?? []; + const emptyText = componentConfig.emptyText ?? "데이터 없음"; + const previewTitleField = titleField ?? "title"; + const previewSubtitleField = subtitleFields[0]; + const previewMetricField = metricFields[0]?.column; + + const userContext = React.useMemo( + () => ({ + companyCode: (p.companyCode as string) || undefined, + userId: (p.userId as string) || undefined, + deptCode: (p.deptCode as string) || undefined, + userName: (p.userName as string) || undefined, + }), + [p.companyCode, p.userId, p.deptCode, p.userName], + ); + + const previewBuilder = React.useCallback( + (i: number) => ({ + __preview: true, + [previewTitleField]: `샘플 카드 ${i + 1}`, + ...(previewSubtitleField ? { [previewSubtitleField]: `부제 ${i + 1}` } : {}), + ...(previewMetricField + ? { [previewMetricField]: (i + 1) * 7 } + : { count: (i + 1) * 7 }), + }), + [previewMetricField, previewSubtitleField, previewTitleField], + ); + + const { rows, loading, error, isPreview } = useTableRows(componentConfig.dataSource, { + isDesignMode, + formData: (p.formData as Record) || undefined, + userContext, + refreshKey: (p.refreshKey as number | string | undefined) || undefined, + previewCount: 4, + previewBuilder, + }); + + const containerStyle: React.CSSProperties = { + width: "100%", + height: "100%", + display: "flex", + flexDirection: "column", + boxSizing: "border-box", + background: "hsl(var(--card))", + border: "1px solid hsl(var(--border))", + borderRadius: 8, + padding: 12, + minHeight: 160, + ...((component as any).style ?? {}), + ...style, + }; + + if (isDesignMode && isSelected) { + containerStyle.outline = "2px solid hsl(var(--primary))"; + containerStyle.outlineOffset = "2px"; + } + + const handleClick = (e: React.MouseEvent) => { + e.stopPropagation(); + onClick?.(); + }; + + /* eslint-disable @typescript-eslint/no-unused-vars */ + const { + selectedScreen: _1, onZoneComponentDrop: _2, onZoneClick: _3, + componentConfig: _4, component: _5, isSelected: _6, + onClick: _7, onDragStart: _8, onDragEnd: _9, + size: _10, position: _11, style: _12, + screenId: _13, tableName: _14, onRefresh: _15, onClose: _16, + web_type: _17, autoGeneration: _18, isInteractive: _19, + formData: _20, onFormDataChange: _21, + menuId: _22, menuObjid: _23, onSave: _24, + userId: _25, userName: _26, companyCode: _27, deptCode: _27b, + isInModal: _28, readonly: _29, originalData: _30, + selectedRows: _36, selectedRowsData: _37, onSelectedRowsChange: _38, + sortBy: _39, sortOrder: _40, tableDisplayData: _41, + flowSelectedData: _42, flowSelectedStepId: _43, onFlowSelectedDataChange: _44, + onConfigChange: _45, refreshKey: _46, flowRefreshKey: _47, onFlowRefresh: _48, + isPreview: _49, groupedData: _50, + title: _51, layout: _52, columns: _53, dataSource: _54, + titleField: _55, subtitleFields: _56, metricFields: _57, emptyText: _58, + disabled: _59, required: _60, + ...domProps + } = props as any; + /* eslint-enable @typescript-eslint/no-unused-vars */ + + const listContainerStyle: React.CSSProperties = + layout === "grid" + ? { + display: "grid", + gap: 8, + gridTemplateColumns: columns + ? `repeat(auto-fit, minmax(max(200px, calc((100% - ${ + (columns - 1) * 8 + }px) / ${columns})), 1fr))` + : "repeat(auto-fit, minmax(200px, 1fr))", + flex: 1, + minHeight: 0, + overflow: "auto", + } + : { + display: "flex", + flexDirection: "column", + gap: 6, + flex: 1, + minHeight: 0, + overflow: "auto", + }; + + const renderCard = (row: Record, idx: number) => { + const titleText = titleField ? cellToText(row[titleField]) : `행 ${idx + 1}`; + return ( +
+
+
+ {titleText} +
+ {subtitleFields.map((col, sIdx) => ( +
+ {cellToText(row[col])} +
+ ))} +
+ {metricFields.length > 0 && ( +
+ {metricFields.map((m, mIdx) => { + const v = cellToText(row[m.column]); + return ( +
+ {m.label && ( + + {m.label} + + )} + + {v} + +
+ ); + })} +
+ )} +
+ ); + }; + + return ( +
+ {(title || isPreview || loading) && ( +
+ {title || (isPreview ? "카드 리스트 (미리보기)" : "")} + {loading && ( + + 로딩… + + )} +
+ )} +
+ {error ? ( +
+ 데이터 로드 실패: {error} +
+ ) : rows.length === 0 ? ( +
+ {emptyText} +
+ ) : ( + rows.map((row, idx) => renderCard(row, idx)) + )} +
+
+ ); +}; + +export const CardListWrapper: React.FC = (props) => { + return ; +}; diff --git a/frontend/lib/registry/components/card-list/CardListRenderer.tsx b/frontend/lib/registry/components/card-list/CardListRenderer.tsx new file mode 100644 index 00000000..7a94cb5f --- /dev/null +++ b/frontend/lib/registry/components/card-list/CardListRenderer.tsx @@ -0,0 +1,23 @@ +"use client"; + +import React from "react"; +import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer"; +import { CardListDefinition } from "./index"; +import { CardListComponent } from "./CardListComponent"; + +/** + * CardList 렌더러 (Phase G.3.1) — id "card-list" 자가 등록. + */ +export class CardListRenderer extends AutoRegisteringComponentRenderer { + static componentDefinition = CardListDefinition; + + render(): React.ReactElement { + return ; + } +} + +CardListRenderer.registerSelf(); + +if (process.env.NODE_ENV === "development") { + CardListRenderer.enableHotReload(); +} diff --git a/frontend/lib/registry/components/card-list/InvCardListConfigPanel.tsx b/frontend/lib/registry/components/card-list/InvCardListConfigPanel.tsx new file mode 100644 index 00000000..d394efae --- /dev/null +++ b/frontend/lib/registry/components/card-list/InvCardListConfigPanel.tsx @@ -0,0 +1,472 @@ +"use client"; + +/** + * InvCardListConfigPanel — canonical card-list 설정 패널 (Phase G.3.1, cp 톤) + * + * 흐름: + * ① 기본 — 제목 + 레이아웃 (list / grid) + * ② 데이터 소스 — 테이블 + 정렬 컬럼 + limit + * ③ 필드 매핑 — title / subtitles / metrics + * ④ 필터 — OptionFilter 빌더 + */ + +import React from "react"; +import { Plus, Trash2 } from "lucide-react"; +import { + CPSection, + CPRow, + CPText, + CPSelect, + CPSegment, + CPNumber, + CPSwitch, + Hint, +} from "@/components/v2/config-panels/_shared/cp"; +import { useDbTables } from "../common/useDbTables"; +import { OptionFilterRow } from "../_shared/FilterRow"; +import type { OptionFilter } from "../input/use-option-loader"; +import type { + CardListConfig, + CardListDataSource, + CardListLayout, + CardListMetric, +} from "./types"; + +export interface InvCardListConfigPanelProps { + config?: CardListConfig; + onChange?: (config: CardListConfig) => void; + selectedComponent?: { id: string; config?: CardListConfig; [k: string]: any }; +} + +export const InvCardListConfigPanel: React.FC = ({ + config, + onChange, + selectedComponent, +}) => { + const current: CardListConfig = + (config as CardListConfig) || (selectedComponent?.config as CardListConfig) || {}; + const patch = (p: Partial) => onChange?.({ ...current, ...p }); + + const ds = current.dataSource ?? {}; + const patchDataSource = (p: Partial) => { + const next = { ...ds, ...p }; + if ( + !next.tableName && + (!next.filters || next.filters.length === 0) && + (!next.orderBy || next.orderBy.length === 0) + ) { + patch({ dataSource: undefined }); + } else { + patch({ dataSource: next }); + } + }; + + const filters = ds.filters ?? []; + const subtitles = current.subtitleFields ?? []; + const metrics: CardListMetric[] = current.metricFields ?? []; + + const updateFilter = (idx: number, f: Partial) => { + const next = filters.map((it, i) => (i === idx ? { ...it, ...f } : it)); + patchDataSource({ filters: next }); + }; + const addFilter = () => + patchDataSource({ + filters: [...filters, { column: "", operator: "=", value_type: "static", value: "" } as OptionFilter], + }); + const removeFilter = (idx: number) => + patchDataSource({ filters: filters.filter((_, i) => i !== idx).length === 0 ? undefined : filters.filter((_, i) => i !== idx) }); + + const updateSubtitle = (idx: number, v: string) => { + const next = subtitles.map((it, i) => (i === idx ? v : it)); + patch({ subtitleFields: next }); + }; + const addSubtitle = () => patch({ subtitleFields: [...subtitles, ""] }); + const removeSubtitle = (idx: number) => + patch({ + subtitleFields: subtitles.filter((_, i) => i !== idx).length === 0 + ? undefined + : subtitles.filter((_, i) => i !== idx), + }); + + const updateMetric = (idx: number, m: Partial) => { + const next = metrics.map((it, i) => (i === idx ? { ...it, ...m } : it)); + patch({ metricFields: next }); + }; + const addMetric = () => patch({ metricFields: [...metrics, { column: "" }] }); + const removeMetric = (idx: number) => { + const next = metrics.filter((_, i) => i !== idx); + patch({ metricFields: next.length === 0 ? undefined : next }); + }; + + const orderByCol = ds.orderBy?.[0]?.column || ""; + const orderByDir = ds.orderBy?.[0]?.direction || "desc"; + + const { options: tableOptions } = useDbTables(); + + return ( +
+ {/* ── ① 기본 ─────────────────────────── */} + + + patch({ title: v || undefined })} + placeholder="부서별 카드" + /> + + + patch({ layout: v as CardListLayout })} + options={[ + { value: "list", label: "리스트" }, + { value: "grid", label: "그리드" }, + ]} + /> + + {(current.layout ?? "list") === "grid" && ( + + patch({ columns: v ?? undefined })} + min={1} + max={8} + placeholder="auto" + /> + + )} + + patch({ emptyText: v || undefined })} + placeholder="데이터 없음" + /> + + + + {/* ── ② 데이터 소스 ─────────────────────────── */} + + + patchDataSource({ tableName: v || undefined })} + > + + {tableOptions.map((t) => ( + + ))} + + + + + patchDataSource({ + orderBy: v + ? [{ column: v, direction: orderByDir as "asc" | "desc" }] + : undefined, + }) + } + placeholder="created_date" + /> + + {orderByCol && ( + + + patchDataSource({ + orderBy: [{ column: orderByCol, direction: v as "asc" | "desc" }], + }) + } + options={[ + { value: "desc", label: "내림차순" }, + { value: "asc", label: "오름차순" }, + ]} + /> + + )} + + patchDataSource({ limit: v ?? 50 })} + min={1} + max={500} + /> + + + + {/* ── ③ 필드 매핑 ─────────────────────────── */} + + + patch({ titleField: v || undefined })} + placeholder="dept_name" + /> + + +
+ + 부제 컬럼 {subtitles.length > 0 && `(${subtitles.length})`} + + +
+ {subtitles.length === 0 ? ( + 부제 없음 + ) : ( +
+ {subtitles.map((col, idx) => ( + updateSubtitle(idx, v)} + onRemove={() => removeSubtitle(idx)} + /> + ))} +
+ )} + +
+ + metric 칩 {metrics.length > 0 && `(${metrics.length})`} + + +
+ {metrics.length === 0 ? ( + metric 칩 없음 — 카드 우측/하단 숫자 강조 영역 빈 상태 + ) : ( +
+ {metrics.map((m, idx) => ( + updateMetric(idx, p)} + onRemove={() => removeMetric(idx)} + /> + ))} +
+ )} +
+ + {/* ── ④ 필터 ─────────────────────────── */} + 0 ? `(${filters.length})` : ""}`}> +
+ +
+ {filters.length === 0 ? ( + 필터 없음 — 테이블 전체 row + ) : ( +
+ {filters.map((f, idx) => ( + updateFilter(idx, p)} + onRemove={() => removeFilter(idx)} + tableName={ds.tableName} + /> + ))} +
+ )} +
+
+ ); +}; + +InvCardListConfigPanel.displayName = "InvCardListConfigPanel"; + +function SubtitleRow({ + value, + onChange, + onRemove, +}: { + value: string; + onChange: (v: string) => void; + onRemove: () => void; +}) { + return ( +
+ onChange(e.target.value)} + placeholder="컬럼명" + style={{ + height: 22, + padding: "0 6px", + fontSize: 11, + fontFamily: "var(--v5-font-sans)", + background: "var(--cp-surface)", + border: "1px solid var(--cp-border)", + borderRadius: 3, + color: "var(--cp-text)", + outline: "none", + minWidth: 0, + }} + /> + +
+ ); +} + +function MetricRow({ + metric, + onChange, + onRemove, +}: { + metric: CardListMetric; + onChange: (p: Partial) => void; + onRemove: () => void; +}) { + return ( +
+ onChange({ column: e.target.value })} + placeholder="컬럼명" + style={inputBase()} + /> + onChange({ label: e.target.value || undefined })} + placeholder="라벨" + style={inputBase()} + /> + + onChange({ emphasis: v })} /> + 강조 + + +
+ ); +} + +function inputBase(): React.CSSProperties { + return { + height: 22, + padding: "0 6px", + fontSize: 11, + fontFamily: "var(--v5-font-sans)", + background: "var(--cp-surface)", + border: "1px solid var(--cp-border)", + borderRadius: 3, + color: "var(--cp-text)", + outline: "none", + minWidth: 0, + }; +} + +export default InvCardListConfigPanel; diff --git a/frontend/lib/registry/components/card-list/index.ts b/frontend/lib/registry/components/card-list/index.ts new file mode 100644 index 00000000..936b4beb --- /dev/null +++ b/frontend/lib/registry/components/card-list/index.ts @@ -0,0 +1,44 @@ +"use client"; + +import { createComponentDefinition } from "../../utils/createComponentDefinition"; +import { ComponentCategory } from "@/types/component"; +import { CardListWrapper } from "./CardListComponent"; +import { InvCardListConfigPanel } from "./InvCardListConfigPanel"; +import type { CardListConfig } from "./types"; + +/** + * CardList — canonical 카드 리스트 컴포넌트 (Phase G.3.1) + * + * 테이블 row 들을 카드 카탈로그처럼 반복 출력. titleField / subtitleFields / + * metricFields 조합으로 다양한 도메인 (부서별 카드 / 사용자 카드 / 품목 카드) 표현. + */ + +const DEFAULT_CONFIG: Partial = { + layout: "list", +}; + +export const CardListDefinition = createComponentDefinition({ + id: "card-list", + name: "카드 리스트", + name_eng: "Card List", + description: "테이블 row 들을 카드 카탈로그 형태로 반복 출력", + category: ComponentCategory.DISPLAY, + web_type: "text", + component: CardListWrapper, + default_config: DEFAULT_CONFIG as Record, + default_size: { width: 360, height: 280 }, + config_panel: InvCardListConfigPanel, + icon: "LayoutList", + tags: ["카드", "list", "card", "catalog", "view"], + version: "1.0.0", + author: "INVYONE", + documentation: + "notes/gbpark/2026-05-14-studio-data-view-roadmap.md#phase-g31-progress", + dataPorts: { + inputs: [{ name: "data", type: "rows" }], + }, +}); + +export type { CardListConfig, CardListLayout, CardListMetric, CardListDataSource } from "./types"; +export { CardListComponent, CardListWrapper } from "./CardListComponent"; +export { InvCardListConfigPanel } from "./InvCardListConfigPanel"; diff --git a/frontend/lib/registry/components/card-list/types.ts b/frontend/lib/registry/components/card-list/types.ts new file mode 100644 index 00000000..d937776d --- /dev/null +++ b/frontend/lib/registry/components/card-list/types.ts @@ -0,0 +1,47 @@ +"use client"; + +import type { ComponentConfig } from "@/types/component"; +import type { OptionFilter } from "@/lib/registry/components/input/use-option-loader"; +import type { SelectRowsOrderBy } from "@/lib/api/stats"; + +/** + * canonical card-list 설정 타입 (Phase G.3.1) + * + * 한 테이블에서 가져온 row 들을 카드 카탈로그처럼 반복 출력. 카드 한 장당 + * `titleField` 가 위에, `subtitleFields` 가 아래 작은 글씨로, `metricFields` 는 + * 우측 또는 하단에 숫자 / 라벨 칩으로 표시. + */ + +export type CardListLayout = "list" | "grid"; + +export interface CardListMetric { + /** 표시할 column 이름 */ + column: string; + /** 카드 위 보일 라벨 (없으면 column 그대로) */ + label?: string; + /** primary color glow 강조 (기본 false) */ + emphasis?: boolean; +} + +export interface CardListDataSource { + tableName?: string; + filters?: OptionFilter[]; + orderBy?: SelectRowsOrderBy[]; + limit?: number; +} + +export interface CardListConfig extends ComponentConfig { + title?: string; + layout?: CardListLayout; + /** grid 모드일 때 한 줄 카드 수 (default auto-fit 200px). 명시하면 컬럼 상한 */ + columns?: number; + dataSource?: CardListDataSource; + /** 카드 제목으로 쓸 컬럼 */ + titleField?: string; + /** 카드 부제 컬럼들 — 위에서부터 라인별 표시 */ + subtitleFields?: string[]; + /** 카드 우측/하단의 metric 칩들 */ + metricFields?: CardListMetric[]; + /** 빈 결과일 때 표시 문구 (기본: "데이터 없음") */ + emptyText?: string; +} diff --git a/frontend/lib/registry/components/chart/ChartComponent.tsx b/frontend/lib/registry/components/chart/ChartComponent.tsx new file mode 100644 index 00000000..a4075a00 --- /dev/null +++ b/frontend/lib/registry/components/chart/ChartComponent.tsx @@ -0,0 +1,295 @@ +"use client"; + +import React from "react"; +import { + Bar, + BarChart, + CartesianGrid, + Cell, + Legend, + Line, + LineChart, + Pie, + PieChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import type { ComponentRendererProps } from "@/types/component"; +import { filterDOMProps } from "@/lib/utils/domPropsFilter"; +import { useChartData } from "./use-chart-data"; +import type { ChartConfig, ChartType } from "./types"; + +/** + * Chart — canonical 차트 컴포넌트 (Phase G.3) + * + * `dataSource.tableName + groupBy + aggregation` 으로 `/aggregate-group` 호출, + * recharts 위에서 4종 차트 (bar / horizontalBar / line / donut) 렌더. + * + * 디자인 모드: API 호출 안 함. 정적 preview row 4 개로 모양만 보여줌. + * dataSource 미완성: 같은 preview 로 fallback. + * 운영 모드 fetch 실패: error message 표시. + */ + +const VALID_TYPES: ChartType[] = ["bar", "horizontalBar", "line", "donut"]; + +const DEFAULT_PALETTE = [ + "#6c5ce7", // primary purple + "#00cec9", // cyan + "#fd79a8", // pink + "#fdcb6e", // amber + "#74b9ff", // soft blue + "#55efc4", // mint + "#a29bfe", // lavender + "#ffeaa7", // pale yellow +]; + +export interface ChartComponentProps extends ComponentRendererProps { + config?: ChartConfig; +} + +export const ChartComponent: React.FC = ({ + component, + isDesignMode = false, + isSelected = false, + onClick, + onDragStart, + onDragEnd, + config, + className, + style, + ...props +}) => { + const p = props as any; + const fromProps: Partial = {}; + if (typeof p.chartType === "string" && (VALID_TYPES as string[]).includes(p.chartType)) + fromProps.chartType = p.chartType as ChartType; + if (typeof p.title === "string") fromProps.title = p.title; + if (p.dataSource && typeof p.dataSource === "object") fromProps.dataSource = p.dataSource; + if (typeof p.includeEmptyGroup === "boolean") fromProps.includeEmptyGroup = p.includeEmptyGroup; + if (typeof p.barColor === "string") fromProps.barColor = p.barColor; + if (Array.isArray(p.donutPalette)) fromProps.donutPalette = p.donutPalette; + if (typeof p.showAxis === "boolean") fromProps.showAxis = p.showAxis; + if (typeof p.showLegend === "boolean") fromProps.showLegend = p.showLegend; + + const componentConfig = { + ...config, + ...((component as any).config ?? {}), + ...((component as any).componentConfig ?? {}), + ...fromProps, + } as ChartConfig; + + const chartType: ChartType = (VALID_TYPES as string[]).includes( + componentConfig.chartType as string, + ) + ? (componentConfig.chartType as ChartType) + : "bar"; + + const title = componentConfig.title; + const barColor = componentConfig.barColor || "#6c5ce7"; + const donutPalette = + componentConfig.donutPalette && componentConfig.donutPalette.length > 0 + ? componentConfig.donutPalette + : DEFAULT_PALETTE; + const showAxis = componentConfig.showAxis ?? true; + const showLegend = componentConfig.showLegend ?? true; + + const userContext = React.useMemo( + () => ({ + companyCode: (p.companyCode as string) || undefined, + userId: (p.userId as string) || undefined, + deptCode: (p.deptCode as string) || undefined, + userName: (p.userName as string) || undefined, + }), + [p.companyCode, p.userId, p.deptCode, p.userName], + ); + + const { rows, loading, error, isPreview } = useChartData(componentConfig.dataSource, { + isDesignMode, + formData: (p.formData as Record) || undefined, + userContext, + refreshKey: (p.refreshKey as number | string | undefined) || undefined, + includeEmptyGroup: componentConfig.includeEmptyGroup, + }); + + const containerStyle: React.CSSProperties = { + width: "100%", + height: "100%", + display: "flex", + flexDirection: "column", + boxSizing: "border-box", + background: "hsl(var(--card))", + border: "1px solid hsl(var(--border))", + borderRadius: 8, + padding: 12, + minHeight: 200, + ...((component as any).style ?? {}), + ...style, + }; + + if (isDesignMode && isSelected) { + containerStyle.outline = "2px solid hsl(var(--primary))"; + containerStyle.outlineOffset = "2px"; + } + + const handleClick = (e: React.MouseEvent) => { + e.stopPropagation(); + onClick?.(); + }; + + /* eslint-disable @typescript-eslint/no-unused-vars */ + const { + selectedScreen: _1, onZoneComponentDrop: _2, onZoneClick: _3, + componentConfig: _4, component: _5, isSelected: _6, + onClick: _7, onDragStart: _8, onDragEnd: _9, + size: _10, position: _11, style: _12, + screenId: _13, tableName: _14, onRefresh: _15, onClose: _16, + web_type: _17, autoGeneration: _18, isInteractive: _19, + formData: _20, onFormDataChange: _21, + menuId: _22, menuObjid: _23, onSave: _24, + userId: _25, userName: _26, companyCode: _27, deptCode: _27b, + isInModal: _28, readonly: _29, originalData: _30, + selectedRows: _36, selectedRowsData: _37, onSelectedRowsChange: _38, + sortBy: _39, sortOrder: _40, tableDisplayData: _41, + flowSelectedData: _42, flowSelectedStepId: _43, onFlowSelectedDataChange: _44, + onConfigChange: _45, refreshKey: _46, flowRefreshKey: _47, onFlowRefresh: _48, + isPreview: _49, groupedData: _50, + chartType: _51, title: _52, dataSource: _53, + includeEmptyGroup: _54, barColor: _55, donutPalette: _56, + showAxis: _57, showLegend: _58, + disabled: _59, required: _60, + ...domProps + } = props as any; + /* eslint-enable @typescript-eslint/no-unused-vars */ + + // recharts 데이터 형식 (group/value → name/value) + const data = React.useMemo( + () => + rows.map((r) => ({ + name: r.group === null || r.group === "" ? "—" : String(r.group), + value: r.value, + })), + [rows], + ); + + const renderChart = (): React.ReactNode => { + if (data.length === 0) { + return ( +
+ {error ? `데이터 로드 실패: ${error}` : "표시할 데이터가 없습니다."} +
+ ); + } + + switch (chartType) { + case "donut": + return ( + + + + {data.map((_, i) => ( + + ))} + + + {showLegend && } + + + ); + case "horizontalBar": + return ( + + + + {showAxis && } + {showAxis && ( + + )} + + + + + ); + case "line": + return ( + + + + {showAxis && } + {showAxis && } + + + + + ); + case "bar": + default: + return ( + + + + {showAxis && } + {showAxis && } + + + + + ); + } + }; + + return ( +
+ {(title || isPreview || loading) && ( +
+ {title || (isPreview ? "차트 (미리보기)" : "")} + {loading && ( + + 로딩… + + )} +
+ )} +
{renderChart()}
+
+ ); +}; + +export const ChartWrapper: React.FC = (props) => { + return ; +}; diff --git a/frontend/lib/registry/components/chart/ChartRenderer.tsx b/frontend/lib/registry/components/chart/ChartRenderer.tsx new file mode 100644 index 00000000..8c736b0d --- /dev/null +++ b/frontend/lib/registry/components/chart/ChartRenderer.tsx @@ -0,0 +1,27 @@ +"use client"; + +import React from "react"; +import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer"; +import { ChartDefinition } from "./index"; +import { ChartComponent } from "./ChartComponent"; + +/** + * Chart 렌더러 (Phase G.3) + * + * AutoRegisteringComponentRenderer 상속으로 ComponentRegistry 에 "chart" 자동 등록. + * 기존 `frontend/lib/registry/components/ChartRenderer.tsx` (placeholder, 미사용) + * 와는 별개. index.ts 가 import 하는 경로는 본 파일. + */ +export class ChartRenderer extends AutoRegisteringComponentRenderer { + static componentDefinition = ChartDefinition; + + render(): React.ReactElement { + return ; + } +} + +ChartRenderer.registerSelf(); + +if (process.env.NODE_ENV === "development") { + ChartRenderer.enableHotReload(); +} diff --git a/frontend/lib/registry/components/chart/InvChartConfigPanel.tsx b/frontend/lib/registry/components/chart/InvChartConfigPanel.tsx new file mode 100644 index 00000000..e89123a8 --- /dev/null +++ b/frontend/lib/registry/components/chart/InvChartConfigPanel.tsx @@ -0,0 +1,264 @@ +"use client"; + +/** + * InvChartConfigPanel — canonical 차트 컴포넌트 (id: chart) cp 톤 설정 패널 (Phase G.3) + * + * 흐름: + * ① 기본 — 제목 + 차트 종류 (CPVisualGrid 4종) + * ② 데이터 소스 — 테이블 / groupBy / 집계 / valueColumn + * ③ 필터 — OptionFilter 빌더 (stats 와 같은 패턴 재사용) + * ④ 표시 옵션 — 최대 그룹 / 정렬 방향 / 빈 그룹 / 축 / 범례 / 색 + * + * Reference: notes/gbpark/2026-04-28-cp-panel-standard.md + */ + +import React from "react"; +import { Plus, BarChart3, BarChart2, PieChart as PieIcon, LineChart as LineIcon } from "lucide-react"; +import { + CPSection, + CPRow, + CPText, + CPSelect, + CPSegment, + CPNumber, + CPColor, + CPVisualGrid, + CPSwitch, + Hint, +} from "@/components/v2/config-panels/_shared/cp"; +import { useDbTables } from "../common/useDbTables"; +import { OptionFilterRow } from "../_shared/FilterRow"; +import { ColumnPicker } from "../_shared/ColumnPicker"; +import type { OptionFilter } from "../input/use-option-loader"; +import type { ChartConfig, ChartType, ChartDataSource } from "./types"; +import type { StatsAggregation } from "../stats/types"; + +const CHART_TYPES: Array<{ value: ChartType; label: string; preview: React.ReactNode; desc: string }> = [ + { value: "bar", label: "막대", preview: , desc: "세로 막대 그래프" }, + { value: "horizontalBar", label: "가로 막대", preview: , desc: "가로 막대 그래프" }, + { value: "line", label: "선", preview: , desc: "추세선" }, + { value: "donut", label: "도넛", preview: , desc: "도넛형 비율" }, +]; + +const AGGREGATION_OPTS: Array<{ value: StatsAggregation; label: string }> = [ + { value: "count", label: "건수" }, + { value: "distinctCount", label: "고유 건수" }, + { value: "sum", label: "합계" }, + { value: "avg", label: "평균" }, + { value: "min", label: "최소" }, + { value: "max", label: "최대" }, +]; + +export interface InvChartConfigPanelProps { + config?: ChartConfig; + onChange?: (config: ChartConfig) => void; + selectedComponent?: { id: string; config?: ChartConfig; [k: string]: any }; +} + +export const InvChartConfigPanel: React.FC = ({ + config, + onChange, + selectedComponent, +}) => { + const current: ChartConfig = + (config as ChartConfig) || (selectedComponent?.config as ChartConfig) || {}; + const patch = (p: Partial) => onChange?.({ ...current, ...p }); + + const ds = current.dataSource ?? {}; + const patchDataSource = (p: Partial) => { + const next = { ...ds, ...p }; + if (!next.tableName && !next.groupBy && (!next.filters || next.filters.length === 0) && !next.valueColumn) { + patch({ dataSource: undefined }); + } else { + patch({ dataSource: next }); + } + }; + + const aggregation: StatsAggregation = ds.aggregation ?? "count"; + const needsValueColumn = aggregation !== "count"; + + const filters = ds.filters ?? []; + const updateFilter = (idx: number, f: Partial) => { + const next = filters.map((it, i) => (i === idx ? { ...it, ...f } : it)); + patchDataSource({ filters: next }); + }; + const addFilter = () => { + patchDataSource({ + filters: [ + ...filters, + { column: "", operator: "=", value_type: "static", value: "" } as OptionFilter, + ], + }); + }; + const removeFilter = (idx: number) => { + const next = filters.filter((_, i) => i !== idx); + patchDataSource({ filters: next.length === 0 ? undefined : next }); + }; + + const { options: tableOptions } = useDbTables(); + + return ( +
+ {/* ── ① 기본 ─────────────────────────── */} + + + patch({ title: v || undefined })} + placeholder="부서별 인원" + /> + +
+ + patch({ chartType: v as ChartType })} + options={CHART_TYPES} + /> +
+
+ + {/* ── ② 데이터 소스 ─────────────────────────── */} + + + patchDataSource({ tableName: v || undefined })} + > + + {tableOptions.map((t) => ( + + ))} + + + + patchDataSource({ groupBy: v || undefined })} + placeholder="그룹 컬럼 선택…" + /> + + + patchDataSource({ aggregation: (v as StatsAggregation) || "count" })} + > + {AGGREGATION_OPTS.map((o) => ( + + ))} + + + {(needsValueColumn || ds.valueColumn) && ( + + patchDataSource({ valueColumn: v || undefined })} + placeholder="값 컬럼 선택…" + /> + + )} + + + {/* ── ③ 필터 ─────────────────────────── */} + 0 ? `(${filters.length})` : ""}`}> +
+ +
+ {filters.length === 0 ? ( + 필터 없음 — 테이블 전체 그룹별 집계 + ) : ( +
+ {filters.map((f, idx) => ( + updateFilter(idx, p)} + onRemove={() => removeFilter(idx)} + tableName={ds.tableName} + /> + ))} +
+ )} +
+ + {/* ── ④ 표시 옵션 ─────────────────────────── */} + + + patchDataSource({ limit: v ?? 12 })} + min={1} + max={500} + /> + + + patchDataSource({ orderDir: v as "asc" | "desc" })} + options={[ + { value: "desc", label: "값 큰 순" }, + { value: "asc", label: "값 작은 순" }, + ]} + /> + + + patch({ includeEmptyGroup: v })} + /> + + + patch({ showAxis: v })} + /> + + {(current.chartType ?? "bar") === "donut" && ( + + patch({ showLegend: v })} + /> + + )} + {(current.chartType ?? "bar") !== "donut" && ( + + patch({ barColor: v || undefined })} + /> + + )} + +
+ ); +}; + +InvChartConfigPanel.displayName = "InvChartConfigPanel"; + +export default InvChartConfigPanel; diff --git a/frontend/lib/registry/components/chart/index.ts b/frontend/lib/registry/components/chart/index.ts new file mode 100644 index 00000000..afb29004 --- /dev/null +++ b/frontend/lib/registry/components/chart/index.ts @@ -0,0 +1,51 @@ +"use client"; + +import { createComponentDefinition } from "../../utils/createComponentDefinition"; +import { ComponentCategory } from "@/types/component"; +import { ChartWrapper } from "./ChartComponent"; +import { InvChartConfigPanel } from "./InvChartConfigPanel"; +import type { ChartConfig } from "./types"; + +/** + * Chart — canonical 차트 컴포넌트 (Phase G.3) + * + * 4종 차트 (bar / horizontalBar / line / donut) 통합. `dataSource.tableName + + * groupBy + aggregation` 으로 백엔드 `/aggregate-group` endpoint 호출 → recharts + * 위에서 렌더. + * + * 관련 문서: + * notes/gbpark/2026-05-14-studio-data-view-roadmap.md (Phase G.3 Progress) + */ + +const DEFAULT_CONFIG: Partial = { + chartType: "bar", + showAxis: true, + showLegend: true, +}; + +export const ChartDefinition = createComponentDefinition({ + id: "chart", + name: "차트", + name_eng: "Chart", + description: "테이블 데이터를 막대 / 도넛 / 선 / 가로막대 차트로 표시", + category: ComponentCategory.DISPLAY, + web_type: "text", + component: ChartWrapper, + default_config: DEFAULT_CONFIG as Record, + default_size: { width: 480, height: 280 }, + config_panel: InvChartConfigPanel, + icon: "BarChart3", + tags: ["차트", "chart", "graph", "bar", "donut", "line", "aggregation"], + version: "1.0.0", + author: "INVYONE", + documentation: + "notes/gbpark/2026-05-14-studio-data-view-roadmap.md#phase-g3-progress", + // ─── INVYONE DataPort 선언 (Phase G.0 wiring 비활성 유지) ─── + dataPorts: { + inputs: [{ name: "data", type: "rows" }], + }, +}); + +export type { ChartConfig, ChartType, ChartDataSource } from "./types"; +export { ChartComponent, ChartWrapper } from "./ChartComponent"; +export { InvChartConfigPanel } from "./InvChartConfigPanel"; diff --git a/frontend/lib/registry/components/chart/types.ts b/frontend/lib/registry/components/chart/types.ts new file mode 100644 index 00000000..6334a911 --- /dev/null +++ b/frontend/lib/registry/components/chart/types.ts @@ -0,0 +1,48 @@ +"use client"; + +import type { ComponentConfig } from "@/types/component"; +import type { OptionFilter } from "@/lib/registry/components/input/use-option-loader"; +import type { StatsAggregation } from "@/lib/registry/components/stats/types"; + +/** + * canonical chart 컴포넌트 설정 타입 (Phase G.3) + * + * recharts 기반 4종 차트 (bar / horizontalBar / line / donut) 의 공통 컨피그. + * 데이터 모델은 `tableName + groupBy + aggregation` 으로 일관 — 백엔드의 + * `/aggregate-group` endpoint 응답을 그대로 차트로 그린다. + */ + +export type ChartType = "bar" | "horizontalBar" | "line" | "donut"; + +export interface ChartDataSource { + tableName?: string; + groupBy?: string; + aggregation?: StatsAggregation; + /** count 외 집계 (sum / avg / min / max / distinctCount) 에 사용 */ + valueColumn?: string; + /** OptionFilter 와 동일한 모양 — runtime hook 이 value_type 치환 후 백엔드에 전달 */ + filters?: OptionFilter[]; + /** 표시할 최대 그룹 수 (기본 12). 백엔드는 1~500 까지 허용 */ + limit?: number; + /** "desc" = 값 큰 순, "asc" = 값 작은 순 (기본 desc) */ + orderDir?: "asc" | "desc"; +} + +export interface ChartConfig extends ComponentConfig { + /** 차트 종류 */ + chartType?: ChartType; + /** 차트 제목 (선택) */ + title?: string; + /** 데이터 소스 — 없으면 디자인 placeholder 만 표시 */ + dataSource?: ChartDataSource; + /** 빈 그룹 (null / "") 도 표시할지 여부. 기본 false (필터 아웃) */ + includeEmptyGroup?: boolean; + /** bar / horizontalBar / line 의 막대/선 색상 (기본 primary) */ + barColor?: string; + /** donut 슬라이스 팔레트 (없으면 기본 6색) */ + donutPalette?: string[]; + /** Y 축 / X 축 라벨 표시 여부 (기본 true) */ + showAxis?: boolean; + /** 범례 표시 여부 (기본 true, donut 만 의미) */ + showLegend?: boolean; +} diff --git a/frontend/lib/registry/components/chart/use-chart-data.ts b/frontend/lib/registry/components/chart/use-chart-data.ts new file mode 100644 index 00000000..ab7e3564 --- /dev/null +++ b/frontend/lib/registry/components/chart/use-chart-data.ts @@ -0,0 +1,208 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { aggregateTableGroup, type AggregateGroupRow } from "@/lib/api/stats"; +import type { OptionFilter } from "@/lib/registry/components/input/use-option-loader"; +import type { ChartDataSource } from "./types"; + +/** + * use-chart-data — Phase G.3 + * + * canonical chart 의 `dataSource` 가 채워져 있으면 `/aggregate-group` endpoint 를 + * 호출해 그룹별 집계 row 배열을 반환한다. + * + * - 디자인 모드: fetch 0건. preview rows 로 fallback + * - request key 캐시 (같은 호출 중복 발생 안함) + * - version counter race guard + * - 실패 시 throw 안 하고 error state 로 반환 — 컴포넌트가 fallback 표시 + * + * use-stats-data 와 같은 패턴 (StatsItem 단위 → 차트 단위 한 개) 으로 구현. + */ + +export interface UseChartDataOptions { + isDesignMode?: boolean; + formData?: Record; + userContext?: { + companyCode?: string; + userId?: string; + deptCode?: string; + userName?: string; + }; + refreshKey?: number | string; + /** preview 모드 row 수 (디자인 모드 / dataSource 미설정 시) — 기본 4 */ + previewCount?: number; + /** 빈 그룹 (null/"") 필터링 — `ChartConfig.includeEmptyGroup` 와 연동 */ + includeEmptyGroup?: boolean; +} + +export interface ChartDataState { + rows: AggregateGroupRow[]; + loading: boolean; + error?: string; + /** dataSource 가 아직 채워지지 않아 preview 만 보여주고 있는지 */ + isPreview: boolean; +} + +const DEFAULT_PREVIEW: AggregateGroupRow[] = [ + { group: "A", value: 28 }, + { group: "B", value: 19 }, + { group: "C", value: 12 }, + { group: "D", value: 6 }, +]; + +function resolveFilters( + filters: OptionFilter[] | undefined, + formData: Record | undefined, + userContext: UseChartDataOptions["userContext"], +): { resolved: Array<{ column: string; operator: string; value?: unknown }>; ok: boolean } { + if (!filters || filters.length === 0) return { resolved: [], ok: true }; + const out: Array<{ column: string; operator: string; value?: unknown }> = []; + for (const f of filters) { + if (!f || !f.column) continue; + const op = f.operator ?? "="; + if (op === "isNull" || op === "isNotNull") { + out.push({ column: f.column, operator: op }); + continue; + } + let value: unknown = f.value; + const valueType = f.value_type ?? "static"; + if (valueType === "field") { + const ref = f.field_ref; + if (!ref) return { resolved: [], ok: false }; + value = formData?.[ref]; + if (value === undefined) return { resolved: [], ok: false }; + } else if (valueType === "user") { + const uf = f.user_field; + if (!uf) return { resolved: [], ok: false }; + value = userContext?.[uf]; + if (value === undefined || value === "") return { resolved: [], ok: false }; + } + if (value === undefined || value === null) continue; + out.push({ column: f.column, operator: op, value }); + } + return { resolved: out, ok: true }; +} + +function buildRequestKey( + ds: ChartDataSource, + filters: Array<{ column: string; operator: string; value?: unknown }>, + includeEmptyGroup?: boolean, +): string { + return JSON.stringify({ + t: ds.tableName, + g: ds.groupBy, + a: ds.aggregation ?? "count", + vc: ds.valueColumn ?? "", + l: ds.limit ?? 12, + o: ds.orderDir ?? "desc", + f: filters, + e: !!includeEmptyGroup, + }); +} + +function isDataSourceReady(ds: ChartDataSource | undefined): boolean { + if (!ds || !ds.tableName || !ds.groupBy) return false; + const agg = ds.aggregation ?? "count"; + if (agg === "count") return true; + return !!ds.valueColumn; +} + +export function useChartData( + dataSource: ChartDataSource | undefined, + opts: UseChartDataOptions = {}, +): ChartDataState { + const { isDesignMode, formData, userContext, refreshKey, previewCount, includeEmptyGroup } = opts; + + const previewRows = useMemo(() => { + const n = previewCount ?? DEFAULT_PREVIEW.length; + return DEFAULT_PREVIEW.slice(0, Math.max(2, Math.min(8, n))); + }, [previewCount]); + + const [state, setState] = useState({ + rows: previewRows, + loading: false, + isPreview: true, + }); + + const plan = useMemo(() => { + if (!isDataSourceReady(dataSource)) return { skip: true as const }; + const { resolved, ok } = resolveFilters(dataSource!.filters, formData, userContext); + if (!ok) return { skip: true as const, pending: true as const }; + const reqKey = buildRequestKey(dataSource!, resolved, includeEmptyGroup); + return { + skip: false as const, + reqKey, + req: { + tableName: dataSource!.tableName!, + aggregation: dataSource!.aggregation ?? "count", + groupBy: dataSource!.groupBy!, + valueColumn: dataSource!.valueColumn, + filters: resolved, + limit: dataSource!.limit ?? 12, + orderDir: dataSource!.orderDir ?? "desc", + }, + }; + }, [dataSource, formData, userContext, includeEmptyGroup]); + + const cacheRef = useRef>(new Map()); + const versionRef = useRef(0); + + useEffect(() => { + if (isDesignMode) { + cacheRef.current.clear(); + setState({ rows: previewRows, loading: false, isPreview: true }); + return; + } + + if (plan.skip) { + setState({ rows: previewRows, loading: false, isPreview: true }); + return; + } + + versionRef.current += 1; + const myVersion = versionRef.current; + let cancelled = false; + + if (refreshKey !== undefined) { + cacheRef.current.clear(); + } + + const cached = cacheRef.current.get(plan.reqKey!); + if (cached !== undefined && refreshKey === undefined) { + setState({ rows: cached, loading: false, isPreview: false }); + return; + } + + setState((prev) => ({ ...prev, loading: true, error: undefined })); + + (async () => { + try { + const { rows } = await aggregateTableGroup(plan.req!.tableName, { + aggregation: plan.req!.aggregation as any, + groupBy: plan.req!.groupBy, + valueColumn: plan.req!.valueColumn, + filters: plan.req!.filters as any, + limit: plan.req!.limit, + orderDir: plan.req!.orderDir, + }); + if (cancelled || versionRef.current !== myVersion) return; + const filtered = includeEmptyGroup + ? rows + : rows.filter((r) => r.group !== null && r.group !== ""); + cacheRef.current.set(plan.reqKey!, filtered); + setState({ rows: filtered, loading: false, isPreview: false }); + } catch (err: any) { + if (cancelled || versionRef.current !== myVersion) return; + const msg = err?.response?.data?.message || err?.message || "차트 데이터 로드 실패"; + setState({ rows: [], loading: false, isPreview: false, error: String(msg) }); + } + })(); + + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [plan, isDesignMode, refreshKey, previewRows, includeEmptyGroup]); + + return state; +} diff --git a/frontend/lib/registry/components/common/IconPicker.tsx b/frontend/lib/registry/components/common/IconPicker.tsx index e637120f..4f4e661f 100644 --- a/frontend/lib/registry/components/common/IconPicker.tsx +++ b/frontend/lib/registry/components/common/IconPicker.tsx @@ -33,6 +33,262 @@ const POPULAR = [ "BarChart3", "PieChart", "TrendingUp", "DollarSign", "ShoppingCart", "Package", ]; +/** + * 한글 키워드 → 아이콘 매핑 (검색 시 alias 로 사용). + * lucide 영문 이름과 함께 매치. 자주 쓰일만한 도메인 키워드 위주. + */ +const ICON_KO: Record = { + // 사용자 / 사원 + User: ["사용자", "유저", "사람", "회원", "사원"], + Users: ["사용자", "유저", "사원", "회원", "그룹", "팀", "사람들"], + UserPlus: ["사용자추가", "사원추가", "회원가입", "등록"], + UserMinus: ["사용자삭제", "사원삭제", "탈퇴"], + UserCheck: ["사용자승인", "사원승인", "인증"], + UserX: ["사용자거부", "사원거부", "차단"], + UserCog: ["사용자설정", "권한"], + Contact: ["연락처", "주소록"], + Briefcase: ["가방", "업무", "직장", "프로젝트", "근무"], + Building: ["건물", "회사", "조직"], + Building2: ["빌딩", "회사", "지점", "사업장"], + + // 액션 + Plus: ["추가", "더하기", "생성", "신규"], + Minus: ["빼기", "제거", "감소"], + Edit: ["편집", "수정", "변경"], + Edit2: ["편집", "수정", "변경"], + Edit3: ["편집", "수정"], + Pencil: ["연필", "수정", "편집", "쓰기"], + Trash: ["휴지통", "삭제", "버리기"], + Trash2: ["휴지통", "삭제", "제거"], + Save: ["저장", "보관"], + Check: ["체크", "확인", "완료", "승인"], + CheckCircle: ["완료", "성공", "확인"], + CheckCircle2: ["완료", "성공"], + X: ["닫기", "취소", "엑스"], + XCircle: ["닫기", "실패", "취소"], + Copy: ["복사"], + Clipboard: ["클립보드", "복사", "붙여넣기"], + Send: ["전송", "보내기", "발송"], + Printer: ["인쇄", "프린트"], + Share: ["공유", "공유하기"], + Share2: ["공유"], + Link: ["링크", "연결"], + Link2: ["링크", "연결"], + Unlink: ["연결끊기"], + Power: ["전원", "켜기", "끄기"], + LogIn: ["로그인", "입장"], + LogOut: ["로그아웃", "나가기", "퇴장"], + + // 검색 / 필터 + Search: ["검색", "찾기", "조회", "탐색"], + Filter: ["필터", "거르기", "조건"], + SlidersHorizontal: ["조정", "필터", "설정"], + ListFilter: ["필터", "정렬"], + + // 화살표 / 이동 + ArrowLeft: ["왼쪽", "뒤로", "이전"], + ArrowRight: ["오른쪽", "다음", "앞으로"], + ArrowUp: ["위", "상승"], + ArrowDown: ["아래", "하강"], + ArrowUpDown: ["정렬", "위아래"], + ChevronLeft: ["왼쪽", "이전"], + ChevronRight: ["오른쪽", "다음"], + ChevronUp: ["위"], + ChevronDown: ["아래", "펼침", "드롭다운"], + ChevronsLeft: ["맨앞", "처음"], + ChevronsRight: ["맨뒤", "마지막"], + ChevronsUpDown: ["펼침접힘"], + MoveLeft: ["이동", "왼쪽"], + MoveRight: ["이동", "오른쪽"], + + // 시간 + Calendar: ["달력", "캘린더", "날짜", "일정"], + CalendarDays: ["달력", "일정"], + CalendarCheck: ["일정확인", "예약"], + CalendarClock: ["일정", "예약시간"], + Clock: ["시계", "시간"], + Clock3: ["시간"], + Clock4: ["시간"], + Timer: ["타이머", "시간측정"], + AlarmClock: ["알람", "알람시계"], + Hourglass: ["모래시계", "대기"], + + // 파일 / 폴더 / 문서 + File: ["파일", "문서"], + FileText: ["문서", "텍스트"], + FileSpreadsheet: ["스프레드시트", "엑셀"], + FileImage: ["이미지파일"], + FileVideo: ["동영상파일"], + FilePlus: ["파일추가"], + FileMinus: ["파일삭제"], + FileX: ["파일닫기"], + FileCheck: ["파일확인"], + Files: ["파일들", "여러파일"], + Folder: ["폴더", "디렉토리"], + FolderOpen: ["열린폴더"], + FolderPlus: ["폴더추가"], + FolderMinus: ["폴더삭제"], + Archive: ["아카이브", "보관", "압축"], + + // 다운로드 / 업로드 + Download: ["다운로드", "내려받기"], + Upload: ["업로드", "올리기"], + CloudDownload: ["클라우드다운로드"], + CloudUpload: ["클라우드업로드"], + CloudOff: ["오프라인"], + Cloud: ["클라우드"], + + // 시각 + Eye: ["눈", "보기", "표시", "조회"], + EyeOff: ["숨기기", "안보기"], + + // 보안 + Lock: ["잠금", "자물쇠", "보안"], + Unlock: ["잠금해제"], + Shield: ["방패", "보안", "보호"], + ShieldCheck: ["보안인증"], + Key: ["열쇠", "키", "비밀번호"], + KeyRound: ["키"], + + // 통신 + Mail: ["메일", "이메일", "편지"], + MailOpen: ["메일열기"], + MailPlus: ["메일작성"], + MessageSquare: ["메시지", "댓글"], + MessageCircle: ["메시지"], + Phone: ["전화", "폰"], + PhoneCall: ["통화"], + Smartphone: ["스마트폰", "휴대폰"], + Bell: ["알림", "벨", "종"], + BellOff: ["알림끄기"], + + // 위치 + MapPin: ["위치", "지도", "핀"], + Map: ["지도"], + Navigation: ["네비게이션", "방향"], + Home: ["홈", "집", "메인"], + Building3: ["건물"], + Compass: ["나침반"], + Globe: ["지구", "글로벌", "전세계"], + + // 상태 / 좋아요 + Star: ["별", "즐겨찾기", "별점"], + Heart: ["하트", "좋아요", "즐겨찾기"], + ThumbsUp: ["좋아요", "추천"], + ThumbsDown: ["싫어요"], + Bookmark: ["북마크", "즐겨찾기"], + Award: ["수상", "메달", "상"], + Trophy: ["트로피", "우승"], + Flag: ["깃발", "신고", "표시"], + + // 화폐 / 결제 + DollarSign: ["달러", "돈", "금액", "매출"], + CircleDollarSign: ["달러"], + Banknote: ["지폐", "돈", "현금"], + Wallet: ["지갑"], + CreditCard: ["카드", "신용카드", "결제"], + Receipt: ["영수증"], + Coins: ["동전", "돈"], + Percent: ["퍼센트", "비율", "할인"], + + // 카트 / 쇼핑 + ShoppingCart: ["카트", "장바구니", "쇼핑"], + ShoppingBag: ["쇼핑백", "주문"], + Store: ["상점", "매장"], + Tag: ["태그", "라벨", "꼬리표"], + Tags: ["태그", "라벨"], + + // 박스 / 패키지 + Package: ["패키지", "상자", "포장", "재고"], + PackageOpen: ["박스열기", "배송", "포장"], + PackagePlus: ["박스추가", "입고"], + PackageMinus: ["박스삭제", "출고"], + Box: ["박스", "상자"], + Boxes: ["박스", "재고"], + Truck: ["트럭", "배송"], + Container: ["컨테이너", "보관"], + Warehouse: ["창고", "재고"], + + // 차트 / 통계 + BarChart: ["막대그래프", "차트", "통계"], + BarChart2: ["막대차트"], + BarChart3: ["막대그래프", "차트", "통계", "KPI"], + BarChart4: ["막대차트"], + PieChart: ["파이차트", "비율", "원그래프"], + LineChart: ["선그래프", "추세"], + AreaChart: ["영역차트"], + TrendingUp: ["상승", "증가", "추세"], + TrendingDown: ["하강", "감소"], + Activity: ["활동", "추적"], + Gauge: ["게이지", "측정"], + + // 설정 / 도구 + Settings: ["설정", "환경설정", "옵션"], + Settings2: ["설정"], + Cog: ["설정", "톱니"], + Sliders: ["조정", "슬라이더"], + Wrench: ["렌치", "도구", "수리"], + Hammer: ["망치", "도구"], + + // 새로고침 + RefreshCw: ["새로고침", "재로드"], + RefreshCcw: ["새로고침"], + RotateCw: ["회전"], + RotateCcw: ["회전"], + + // 정보 / 경고 + Info: ["정보", "안내"], + AlertCircle: ["경고", "주의"], + AlertTriangle: ["경고", "위험"], + AlertOctagon: ["위험", "중지"], + HelpCircle: ["도움말", "물음표"], + CircleAlert: ["경고"], + + // 메뉴 + Menu: ["메뉴", "햄버거"], + MoreHorizontal: ["더보기", "추가메뉴"], + MoreVertical: ["더보기"], + List: ["목록", "리스트"], + ListChecks: ["체크리스트"], + Grid: ["그리드", "격자"], + Grid3x3: ["그리드"], + LayoutGrid: ["그리드", "레이아웃"], + + // 데이터 / DB + Database: ["데이터베이스", "DB", "저장소"], + Server: ["서버"], + HardDrive: ["하드드라이브", "저장소"], + Cpu: ["CPU", "프로세서"], + Cable: ["케이블", "연결"], + + // 일정 / 작업 + ClipboardList: ["체크리스트", "할일"], + ClipboardCheck: ["완료", "확인"], + CheckSquare: ["체크박스", "완료"], + Square: ["사각형", "박스"], + + // 기타 자주 쓰이는 + Hash: ["해시", "번호", "샵"], + AtSign: ["골뱅이", "이메일", "아이디"], + Bookmark2: ["북마크"], + Pin: ["핀", "고정"], + PinOff: ["고정해제"], + Sun: ["해", "라이트모드"], + Moon: ["달", "다크모드"], + Zap: ["번개", "빠른"], + Sparkles: ["반짝", "특수효과"], + Gift: ["선물"], + Crown: ["왕관", "VIP"], + Bot: ["로봇", "봇", "자동화"], + Code: ["코드", "개발"], + Terminal: ["터미널", "콘솔"], + Bug: ["버그", "벌레", "오류"], + ListTodo: ["할일", "투두"], + GitBranch: ["분기", "브랜치"], + Workflow: ["워크플로우", "흐름"], + Network: ["네트워크"], +}; + interface IconPickerProps { value?: string; onChange: (iconName: string) => void; @@ -44,35 +300,48 @@ export const IconPicker: React.FC = ({ value, onChange, classNa const [search, setSearch] = useState(""); const [focused, setFocused] = useState(false); const triggerRef = useRef(null); + const popoverRef = useRef(null); const [popoverPos, setPopoverPos] = useState<{ top: number; left: number; width: number; + maxHeight: number; } | null>(null); // 트리거 위치 잡고 popover 열기 (Portal 사용 — 부모 overflow:hidden 영향 회피) const handleToggle = () => { if (!open && triggerRef.current) { const rect = triggerRef.current.getBoundingClientRect(); + const top = rect.bottom + 2; + // popover 높이 = 화면 안에 들어맞게. 너무 크지 않게 360 상한. + const maxHeight = Math.min(360, window.innerHeight - top - 16); setPopoverPos({ - top: rect.bottom + 2, + top, left: rect.left, width: rect.width, + maxHeight: Math.max(180, maxHeight), }); } setOpen((v) => !v); }; - // popover 떠 있는 동안 외부 scroll / resize 발생 시 닫기 - // (좌표 따라가는 대신 닫음 — 사용자가 다시 열면 새 좌표) + // popover 떠 있는 동안 외부 scroll / resize 발생 시 닫기. 단, popover 안의 + // 그리드 자체 스크롤은 제외 — 휠로 아이콘 목록 스크롤 시 닫히지 않게. useEffect(() => { if (!open) return; - const close = () => setOpen(false); - window.addEventListener("scroll", close, true); - window.addEventListener("resize", close); + const onScroll = (e: Event) => { + const target = e.target; + if (target instanceof Node && popoverRef.current?.contains(target)) { + return; // popover 내부 스크롤은 무시 + } + setOpen(false); + }; + const onResize = () => setOpen(false); + window.addEventListener("scroll", onScroll, true); + window.addEventListener("resize", onResize); return () => { - window.removeEventListener("scroll", close, true); - window.removeEventListener("resize", close); + window.removeEventListener("scroll", onScroll, true); + window.removeEventListener("resize", onResize); }; }, [open]); @@ -80,11 +349,24 @@ export const IconPicker: React.FC = ({ value, onChange, classNa if (!search.trim()) { const popularSet = new Set(POPULAR); const popular = ICON_ENTRIES.filter(([n]) => popularSet.has(n)); - const rest = ICON_ENTRIES.filter(([n]) => !popularSet.has(n)).slice(0, 80 - popular.length); + const rest = ICON_ENTRIES.filter(([n]) => !popularSet.has(n)).slice(0, 300 - popular.length); return [...popular, ...rest]; } - const q = search.toLowerCase(); - return ICON_ENTRIES.filter(([name]) => name.toLowerCase().includes(q)).slice(0, 80); + const raw = search.trim(); + const q = raw.toLowerCase(); + const hasHangul = /[가-힣]/.test(raw); + return ICON_ENTRIES.filter(([name]) => { + // 영문 lucide 이름 매치 + if (name.toLowerCase().includes(q)) return true; + // 한글 alias 매치 (한글 검색 시점 우선) + const aliases = ICON_KO[name]; + if (aliases) { + for (const ko of aliases) { + if (hasHangul ? ko.includes(raw) : ko.toLowerCase().includes(q)) return true; + } + } + return false; + }).slice(0, 300); }, [search]); const SelectedIcon = value ? (LucideIcons as any)[value] : null; @@ -105,7 +387,7 @@ export const IconPicker: React.FC = ({ value, onChange, classNa background: "var(--cp-surface)", border: `1px solid ${ focused - ? "rgba(var(--v5-primary-rgb), 0.5)" + ? "hsl(var(--primary) / 0.5)" : "var(--cp-border)" }`, borderRadius: 6, @@ -116,7 +398,7 @@ export const IconPicker: React.FC = ({ value, onChange, classNa gap: 6, fontFamily: "var(--v5-font-sans)", boxShadow: focused - ? "0 0 0 3px rgba(var(--v5-primary-rgb), 0.12)" + ? "0 0 0 3px hsl(var(--primary) / 0.12)" : undefined, transition: "border-color .14s ease, box-shadow .14s ease", textAlign: "left", @@ -151,17 +433,21 @@ export const IconPicker: React.FC = ({ value, onChange, classNa {open && popoverPos && createPortal(
{/* 검색 */} @@ -175,7 +461,7 @@ export const IconPicker: React.FC = ({ value, onChange, classNa type="text" value={search} onChange={(e) => setSearch(e.target.value)} - placeholder="아이콘 검색..." + placeholder="아이콘 검색 (한/영) — 사원, users…" autoFocus style={{ width: "100%", @@ -192,15 +478,16 @@ export const IconPicker: React.FC = ({ value, onChange, classNa />
- {/* 그리드 */} + {/* 그리드 — popover 안에서 flex 로 늘어나며 휠 스크롤 활성 */}
{/* 선택 해제 */} @@ -239,11 +526,11 @@ export const IconPicker: React.FC = ({ value, onChange, classNa style={{ height: 28, background: active - ? "rgba(var(--v5-primary-rgb), 0.10)" + ? "hsl(var(--primary) / 0.10)" : "transparent", border: "none", cursor: "pointer", - color: active ? "var(--v5-primary)" : "var(--cp-text)", + color: active ? "hsl(var(--primary))" : "var(--cp-text)", borderRadius: 4, display: "flex", alignItems: "center", diff --git a/frontend/lib/registry/components/common/useDbColumns.ts b/frontend/lib/registry/components/common/useDbColumns.ts new file mode 100644 index 00000000..71522286 --- /dev/null +++ b/frontend/lib/registry/components/common/useDbColumns.ts @@ -0,0 +1,89 @@ +"use client"; + +/** + * useDbColumns — DB 테이블의 컬럼 목록을 mount/tableName 변경 시 lazy 로드. + * + * 사용처: stats / chart / cardList / groupedTable 의 컬럼 선택 dropdown. + * 모듈 스코프 Map 캐시로 같은 테이블 중복 요청 방지. + */ + +import { useEffect, useMemo, useState } from "react"; + +export interface DbColumnOption { + value: string; + label: string; + data_type?: string; +} + +const cache = new Map(); +const inflight = new Map>(); + +export interface UseDbColumnsResult { + columns: DbColumnOption[]; + loading: boolean; + error?: string; +} + +export function useDbColumns(tableName?: string): UseDbColumnsResult { + const [columns, setColumns] = useState(() => + tableName ? cache.get(tableName) ?? [] : [], + ); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(undefined); + + useEffect(() => { + let cancelled = false; + setError(undefined); + if (!tableName) { + setColumns([]); + setLoading(false); + return; + } + const cached = cache.get(tableName); + if (cached) { + setColumns(cached); + setLoading(false); + return; + } + setLoading(true); + const promise = + inflight.get(tableName) ?? + (async () => { + const { tableManagementApi } = await import("@/lib/api/tableManagement"); + const res = await tableManagementApi.getColumnList(tableName, 1000); + const rows = (res?.success ? res.data?.columns ?? [] : []) as any[]; + const opts: DbColumnOption[] = rows + .map((c) => ({ + value: c?.column_name ?? c?.columnName ?? "", + label: + (c?.display_name ?? c?.displayName ?? "") || + c?.column_name || + c?.columnName || + "", + data_type: c?.data_type ?? c?.dataType, + })) + .filter((o) => !!o.value); + cache.set(tableName, opts); + return opts; + })(); + inflight.set(tableName, promise); + promise + .then((opts) => { + if (cancelled) return; + setColumns(opts); + }) + .catch((e) => { + if (cancelled) return; + setError(e?.message ?? "컬럼 목록 로드 실패"); + }) + .finally(() => { + inflight.delete(tableName); + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [tableName]); + + return useMemo(() => ({ columns, loading, error }), [columns, loading, error]); +} diff --git a/frontend/lib/registry/components/container/ContainerComponent.tsx b/frontend/lib/registry/components/container/ContainerComponent.tsx index ce03a509..e6c95d31 100644 --- a/frontend/lib/registry/components/container/ContainerComponent.tsx +++ b/frontend/lib/registry/components/container/ContainerComponent.tsx @@ -2,14 +2,24 @@ import React, { useState } from "react"; import { ComponentRendererProps } from "@/types/component"; -import { ContainerConfig, ContainerType, ContainerTab } from "./types"; +import { + ContainerConfig, + ContainerType, + ContainerTab, + ContainerChildComponent, +} from "./types"; import { filterDOMProps } from "@/lib/utils/domPropsFilter"; +import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer"; /** * Container — 통합 레이아웃 컨테이너 컴포넌트 * - * containerType 으로 탭/섹션/아코디언/반복/조건부 분기. Phase C-2 최소 구현으로 - * 각 모드는 **스켈레톤 렌더**. 내부 자식 배치는 Phase F 에서 정교화. + * containerType 으로 탭/섹션/아코디언/반복/조건부 분기. + * + * - Phase C-2 최소 구현: 각 모드는 스켈레톤 렌더. + * - Phase G.2: `containerType === "tabs"` 만 활성 탭의 자식 컴포넌트들을 + * `DynamicComponentRenderer` 로 렌더. 비활성 탭은 렌더하지 않음 (성능). + * section / accordion / repeater / conditional 은 여전히 스켈레톤 — 별도 phase. */ const VALID_TYPES: ContainerType[] = [ @@ -135,34 +145,199 @@ export const ContainerComponent: React.FC = ({ { id: "tab2", label: "탭 2" }, { id: "tab3", label: "탭 3" }, ]; + // 디자인 모드에서 setActiveTab 도 허용 (사용자가 탭별 미리보기 전환). + const handleSelect = (id: string) => setActiveTab(id); + const currentTab = tabs.find((t) => t.id === activeTab) ?? tabs[0]; + const activeTabId = currentTab?.id ?? ""; return ( <> -
+
{tabs.map((tab) => ( - ))}
-
-
- [{activeTab}] 탭 컨텐츠 영역 -
+ {/* + 탭 body — 디자인 모드 빌더에서 drop / 선택을 받는 영역. + data 속성 3개로 ScreenDesigner.handleComponentDrop 의 기존 tabs 분기가 그대로 + canonical container 도 인식하게 한다 (별도 코드 패스 추가 없음). + */} +
{ + // 빈 탭 안내 영역까지 포함해 body 클릭 시 탭 자식 선택 해제. + // ChildSlot click 은 stopPropagation 하므로 자식 선택과 충돌하지 않는다. + if (!isDesignMode) return; + if (typeof p.onSelectTabComponent === "function") { + e.stopPropagation(); + p.onSelectTabComponent(activeTabId, "", null); + } + }} + > + {renderTabChildren(currentTab)}
); }; + /** + * 활성 탭 한 개의 자식 컴포넌트만 렌더. 비활성 탭은 mount 자체를 하지 않음 + * (성능 + side-effect 격리). 자식이 비어 있으면 디자인 모드 drop-zone, 운영 + * 모드 빈 영역. + */ + const renderTabChildren = (tab: ContainerTab | undefined): React.ReactNode => { + const childList: ContainerChildComponent[] = tab?.components ?? []; + const tabId = tab?.id ?? ""; + const containerId = (component as any)?.id ?? ""; + const onSelectChild = typeof p.onSelectTabComponent === "function" + ? p.onSelectTabComponent + : undefined; + const selectedChildId: string | undefined = + typeof p.selectedTabComponentId === "string" ? p.selectedTabComponentId : undefined; + const onUpdateContainerComponent = typeof p.onUpdateComponent === "function" + ? p.onUpdateComponent + : undefined; + + if (childList.length === 0) { + if (isDesignMode) { + return ( +
+ 이 탭이 비어 있습니다. 좌측 팔레트에서 컴포넌트를 끌어다 놓으세요. +
+ ); + } + return null; + } + + /** + * 탭에서 특정 자식 삭제. ScreenDesigner 의 onUpdateComponent 가 있으면 그쪽으로 + * 상위 layout 업데이트를 위임. 없으면 no-op (운영 모드 등). + */ + const handleRemoveChild = (childId: string) => { + if (!onUpdateContainerComponent) return; + const nextTabs = (componentConfig.tabs ?? []).map((t) => + t.id === tabId + ? { ...t, components: (t.components ?? []).filter((c) => c.id !== childId) } + : t, + ); + onUpdateContainerComponent({ + ...(component as any), + componentConfig: { + ...((component as any).componentConfig ?? {}), + tabs: nextTabs, + }, + }); + // 선택 해제 + onSelectChild?.(tabId, "", null); + }; + + // 운영 모드는 단순 vertical stack. 빌더의 자유 배치 (size/position) 는 G.2 + // 범위 밖. 차후 phase 에서 builder layout 모드 추가 시 같은 components 배열 + // 위에 좌표 / order 를 얹는 식으로 확장. + return ( +
+ {childList.map((child) => { + const isChildSelected = !!selectedChildId && child.id === selectedChildId; + return ( + { + e.stopPropagation(); + onSelectChild(tabId, child.id, child); + } + : undefined + } + onRemove={onUpdateContainerComponent ? () => handleRemoveChild(child.id) : undefined} + passProps={{ + formData: p.formData, + onFormDataChange: p.onFormDataChange, + userId: p.userId, + userName: p.userName, + companyCode: p.companyCode, + screenId: p.screenId, + menuId: p.menuId, + menuObjid: p.menuObjid, + tableName: p.tableName, + selectedRows: p.selectedRows, + selectedRowsData: p.selectedRowsData, + onSelectedRowsChange: p.onSelectedRowsChange, + refreshKey: p.refreshKey, + onRefresh: p.onRefresh, + isInModal: p.isInModal, + parentTabId: tabId, + parentTabsComponentId: containerId, + }} + /> + ); + })} +
+ ); + }; + // ─── section ─────────────────────────────────────────────────────────── const [collapsed, setCollapsed] = useState(componentConfig.defaultCollapsed ?? false); @@ -290,6 +465,120 @@ export const ContainerComponent: React.FC = ({ ); }; +/** + * ChildSlot — 탭 내부 단일 자식을 `DynamicComponentRenderer` 로 렌더하는 어댑터. + * + * `ContainerChildComponent` 의 `componentType / componentConfig / size` 를 renderer + * 가 기대하는 shape (`component: { componentType, componentConfig }`) 으로 정규화. + * 디자인 모드에서는 클릭 선택 / hover 삭제 버튼 / 선택 outline 을 제공. + */ +const ChildSlot: React.FC<{ + child: ContainerChildComponent; + isDesignMode: boolean; + isSelected?: boolean; + containerId?: string; + tabId?: string; + onSelect?: (e: React.MouseEvent) => void; + onRemove?: () => void; + passProps: Record; +}> = ({ child, isDesignMode, isSelected, containerId, tabId, onSelect, onRemove, passProps }) => { + const [hover, setHover] = React.useState(false); + + const componentForRenderer = React.useMemo( + () => ({ + id: child.id, + componentType: child.componentType, + type: child.componentType, + componentConfig: child.componentConfig ?? {}, + // legacy snake_case 호환 — DynamicComponentRenderer 가 둘 다 읽음 + component_type: child.componentType, + component_config: child.componentConfig ?? {}, + // size 가 있으면 inline style 으로 박스 폭만 통제 (기본은 부모 폭 100%) + size: child.size, + }), + [child.id, child.componentType, child.componentConfig, child.size], + ); + + const wrapStyle: React.CSSProperties = { + width: child.size?.width ?? "100%", + height: child.size?.height, + minHeight: 0, + flexShrink: 0, + position: "relative", + cursor: isDesignMode && onSelect ? "pointer" : undefined, + outline: + isDesignMode && isSelected + ? "2px solid hsl(var(--primary))" + : isDesignMode && hover + ? "1px dashed hsl(var(--primary))" + : "none", + outlineOffset: 2, + borderRadius: 4, + }; + + return ( +
setHover(true) : undefined} + onMouseLeave={isDesignMode ? () => setHover(false) : undefined} + > + {isDesignMode && (isSelected || hover) && onRemove && ( + + )} + {/* + 디자인 모드에서는 자식 컴포넌트의 자체 클릭 핸들러가 ChildSlot 의 선택 click 을 + 가로채지 않도록 pointer-events 차단. 운영 모드에서는 자식이 정상 동작. + */} +
+ +
+
+ ); +}; + export const ContainerWrapper: React.FC = (props) => { return ; }; diff --git a/frontend/lib/registry/components/container/InvContainerConfigPanel.tsx b/frontend/lib/registry/components/container/InvContainerConfigPanel.tsx index 2c208ed2..855ffa79 100644 --- a/frontend/lib/registry/components/container/InvContainerConfigPanel.tsx +++ b/frontend/lib/registry/components/container/InvContainerConfigPanel.tsx @@ -194,6 +194,20 @@ export const InvContainerConfigPanel: React.FC = ( }} placeholder="탭 라벨" /> + + {(tab.components?.length ?? 0) > 0 + ? `${tab.components!.length}개` + : "·"} + ; + /** 빌더에서 표시되는 라벨 (선택) */ + label?: string; + /** 탭 내부 자유 배치용 (선택) — runtime 은 무시 가능 */ + size?: { width?: number | string; height?: number | string }; +} + export interface ContainerTab { id: string; label: string; + /** 탭 헤더 좌측 아이콘 (lucide 이름 또는 이모지, 선택) */ + icon?: string; + /** + * 이 탭이 활성화되었을 때 렌더할 자식 컴포넌트들 (Phase G.2). + * 비어 있으면 디자인 모드에서 drop-zone fallback, 운영 모드에서 빈 영역. + */ + components?: ContainerChildComponent[]; + /** + * 탭 별 데이터 소스 / 필터 / view type (Phase G.2 forward compat). + * runtime 은 현재 무시. 빌더 / 차후 phase 에서 의미 부여. + */ + dataSources?: unknown[]; + filters?: unknown[]; + viewType?: string; } export interface ContainerConfig extends ComponentConfig { diff --git a/frontend/lib/registry/components/grouped-table/GroupedTableComponent.tsx b/frontend/lib/registry/components/grouped-table/GroupedTableComponent.tsx new file mode 100644 index 00000000..807bf992 --- /dev/null +++ b/frontend/lib/registry/components/grouped-table/GroupedTableComponent.tsx @@ -0,0 +1,329 @@ +"use client"; + +import React from "react"; +import type { ComponentRendererProps } from "@/types/component"; +import { filterDOMProps } from "@/lib/utils/domPropsFilter"; +import { useTableRows } from "../_shared/use-table-rows"; +import type { + GroupedTableColumn, + GroupedTableConfig, + GroupedTableDataSource, +} from "./types"; + +/** + * GroupedTable — canonical 그룹 테이블 컴포넌트 (Phase G.3.1) + * + * 백엔드 `/select-rows` 응답을 클라이언트 측에서 `groupBy` 키로 묶고, 그룹 헤더 + + * 안쪽 row 들로 두 단계 표 렌더. 운영 모드 / dataSource 완성 / fetch 성공 시 + * 실제 row 사용. 디자인 모드 / 미완성: 정적 preview. + */ + +const cellToText = (v: unknown): string => { + if (v === null || v === undefined || v === "") return "—"; + if (typeof v === "number") return v.toLocaleString(); + if (typeof v === "boolean") return v ? "✓" : "✗"; + if (v instanceof Date) return v.toLocaleString(); + return String(v); +}; + +export interface GroupedTableComponentProps extends ComponentRendererProps { + config?: GroupedTableConfig; +} + +export const GroupedTableComponent: React.FC = ({ + component, + isDesignMode = false, + isSelected = false, + onClick, + onDragStart, + onDragEnd, + config, + className, + style, + ...props +}) => { + const p = props as any; + const fromProps: Partial = {}; + if (typeof p.title === "string") fromProps.title = p.title; + if (p.dataSource && typeof p.dataSource === "object") fromProps.dataSource = p.dataSource; + if (typeof p.groupBy === "string") fromProps.groupBy = p.groupBy; + if (Array.isArray(p.columns)) fromProps.columns = p.columns; + if (typeof p.sortGroups === "string") fromProps.sortGroups = p.sortGroups; + if (typeof p.emptyText === "string") fromProps.emptyText = p.emptyText; + + const componentConfig = { + ...config, + ...((component as any).config ?? {}), + ...((component as any).componentConfig ?? {}), + ...fromProps, + } as GroupedTableConfig; + + const title = componentConfig.title; + const groupBy = componentConfig.groupBy; + const columns: GroupedTableColumn[] = componentConfig.columns ?? []; + const sortGroups = componentConfig.sortGroups ?? "none"; + const emptyText = componentConfig.emptyText ?? "데이터 없음"; + const previewGroupBy = groupBy ?? "group"; + const previewFirstColumn = columns[0]?.column ?? "name"; + const previewSecondColumn = columns[1]?.column ?? "value"; + + const userContext = React.useMemo( + () => ({ + companyCode: (p.companyCode as string) || undefined, + userId: (p.userId as string) || undefined, + deptCode: (p.deptCode as string) || undefined, + userName: (p.userName as string) || undefined, + }), + [p.companyCode, p.userId, p.deptCode, p.userName], + ); + + const previewBuilder = React.useCallback( + (i: number) => ({ + __preview: true, + [previewGroupBy]: `그룹 ${Math.floor(i / 2) + 1}`, + [previewFirstColumn]: `샘플 ${i + 1}`, + [previewSecondColumn]: (i + 1) * 5, + }), + [previewFirstColumn, previewGroupBy, previewSecondColumn], + ); + + const { rows, loading, error, isPreview } = useTableRows(componentConfig.dataSource, { + isDesignMode, + formData: (p.formData as Record) || undefined, + userContext, + refreshKey: (p.refreshKey as number | string | undefined) || undefined, + previewCount: 6, + previewBuilder, + }); + + // 클라이언트 측 group-by + const grouped = React.useMemo(() => { + if (!groupBy) { + return [{ key: "__all", values: rows }]; + } + const map = new Map[]>(); + const order: string[] = []; + for (const row of rows) { + const raw = row[groupBy]; + const key = raw === null || raw === undefined || raw === "" ? "—" : String(raw); + if (!map.has(key)) { + map.set(key, []); + order.push(key); + } + map.get(key)!.push(row); + } + let keys = order; + if (sortGroups === "asc") keys = [...order].sort((a, b) => a.localeCompare(b, "ko")); + else if (sortGroups === "desc") keys = [...order].sort((a, b) => b.localeCompare(a, "ko")); + return keys.map((k) => ({ key: k, values: map.get(k)! })); + }, [rows, groupBy, sortGroups]); + + // 표시할 컬럼들 — 비어있으면 row 첫 항목의 모든 키 사용 (groupBy 자체는 제외) + const effectiveColumns: GroupedTableColumn[] = React.useMemo(() => { + if (columns.length > 0) return columns; + const sample = rows[0] ?? {}; + return Object.keys(sample) + .filter((k) => k !== groupBy && !k.startsWith("__")) + .map((k) => ({ column: k })); + }, [columns, rows, groupBy]); + + const containerStyle: React.CSSProperties = { + width: "100%", + height: "100%", + display: "flex", + flexDirection: "column", + boxSizing: "border-box", + background: "hsl(var(--card))", + border: "1px solid hsl(var(--border))", + borderRadius: 8, + padding: 12, + minHeight: 180, + ...((component as any).style ?? {}), + ...style, + }; + if (isDesignMode && isSelected) { + containerStyle.outline = "2px solid hsl(var(--primary))"; + containerStyle.outlineOffset = "2px"; + } + + const handleClick = (e: React.MouseEvent) => { + e.stopPropagation(); + onClick?.(); + }; + + /* eslint-disable @typescript-eslint/no-unused-vars */ + const { + selectedScreen: _1, onZoneComponentDrop: _2, onZoneClick: _3, + componentConfig: _4, component: _5, isSelected: _6, + onClick: _7, onDragStart: _8, onDragEnd: _9, + size: _10, position: _11, style: _12, + screenId: _13, tableName: _14, onRefresh: _15, onClose: _16, + web_type: _17, autoGeneration: _18, isInteractive: _19, + formData: _20, onFormDataChange: _21, + menuId: _22, menuObjid: _23, onSave: _24, + userId: _25, userName: _26, companyCode: _27, deptCode: _27b, + isInModal: _28, readonly: _29, originalData: _30, + selectedRows: _36, selectedRowsData: _37, onSelectedRowsChange: _38, + sortBy: _39, sortOrder: _40, tableDisplayData: _41, + flowSelectedData: _42, flowSelectedStepId: _43, onFlowSelectedDataChange: _44, + onConfigChange: _45, refreshKey: _46, flowRefreshKey: _47, onFlowRefresh: _48, + isPreview: _49, groupedData: _50, + title: _51, dataSource: _52, groupBy: _53, + columns: _54, sortGroups: _55, emptyText: _56, + disabled: _57, required: _58, + ...domProps + } = props as any; + /* eslint-enable @typescript-eslint/no-unused-vars */ + + return ( +
+ {(title || isPreview || loading) && ( +
+ {title || (isPreview ? "그룹 테이블 (미리보기)" : "")} + {loading && ( + + 로딩… + + )} +
+ )} +
+ {error ? ( +
+ 데이터 로드 실패: {error} +
+ ) : rows.length === 0 || effectiveColumns.length === 0 ? ( +
+ {emptyText} +
+ ) : ( + + + + {effectiveColumns.map((c, i) => ( + + ))} + + + + {grouped.map((g, gi) => ( + + {groupBy && ( + + + + )} + {g.values.map((row, ri) => ( + + {effectiveColumns.map((c, ci) => ( + + ))} + + ))} + + ))} + +
+ {c.label ?? c.column} +
+ {g.key} + + ({g.values.length}건) + +
+ {cellToText(row[c.column])} +
+ )} +
+
+ ); +}; + +export const GroupedTableWrapper: React.FC = (props) => { + return ; +}; diff --git a/frontend/lib/registry/components/grouped-table/GroupedTableRenderer.tsx b/frontend/lib/registry/components/grouped-table/GroupedTableRenderer.tsx new file mode 100644 index 00000000..25fc2d72 --- /dev/null +++ b/frontend/lib/registry/components/grouped-table/GroupedTableRenderer.tsx @@ -0,0 +1,23 @@ +"use client"; + +import React from "react"; +import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer"; +import { GroupedTableDefinition } from "./index"; +import { GroupedTableComponent } from "./GroupedTableComponent"; + +/** + * GroupedTable 렌더러 (Phase G.3.1) — id "grouped-table" 자가 등록. + */ +export class GroupedTableRenderer extends AutoRegisteringComponentRenderer { + static componentDefinition = GroupedTableDefinition; + + render(): React.ReactElement { + return ; + } +} + +GroupedTableRenderer.registerSelf(); + +if (process.env.NODE_ENV === "development") { + GroupedTableRenderer.enableHotReload(); +} diff --git a/frontend/lib/registry/components/grouped-table/InvGroupedTableConfigPanel.tsx b/frontend/lib/registry/components/grouped-table/InvGroupedTableConfigPanel.tsx new file mode 100644 index 00000000..f6900180 --- /dev/null +++ b/frontend/lib/registry/components/grouped-table/InvGroupedTableConfigPanel.tsx @@ -0,0 +1,362 @@ +"use client"; + +/** + * InvGroupedTableConfigPanel — canonical grouped-table 설정 패널 (Phase G.3.1) + * + * ① 기본 — 제목 + 빈 결과 문구 + * ② 데이터 소스 — 테이블 / 정렬 / limit + * ③ 그룹 — groupBy 컬럼 + 그룹 정렬 + * ④ 컬럼 — 표시할 컬럼 목록 (label / width / align) + * ⑤ 필터 — OptionFilter 빌더 + */ + +import React from "react"; +import { Plus, Trash2 } from "lucide-react"; +import { + CPSection, + CPRow, + CPText, + CPSelect, + CPSegment, + CPNumber, + Hint, +} from "@/components/v2/config-panels/_shared/cp"; +import { useDbTables } from "../common/useDbTables"; +import { OptionFilterRow } from "../_shared/FilterRow"; +import type { OptionFilter } from "../input/use-option-loader"; +import type { + GroupedTableConfig, + GroupedTableColumn, + GroupedTableDataSource, +} from "./types"; + +export interface InvGroupedTableConfigPanelProps { + config?: GroupedTableConfig; + onChange?: (config: GroupedTableConfig) => void; + selectedComponent?: { id: string; config?: GroupedTableConfig; [k: string]: any }; +} + +export const InvGroupedTableConfigPanel: React.FC = ({ + config, + onChange, + selectedComponent, +}) => { + const current: GroupedTableConfig = + (config as GroupedTableConfig) || (selectedComponent?.config as GroupedTableConfig) || {}; + const patch = (p: Partial) => onChange?.({ ...current, ...p }); + + const ds = current.dataSource ?? {}; + const patchDataSource = (p: Partial) => { + const next = { ...ds, ...p }; + if ( + !next.tableName && + (!next.filters || next.filters.length === 0) && + (!next.orderBy || next.orderBy.length === 0) + ) { + patch({ dataSource: undefined }); + } else { + patch({ dataSource: next }); + } + }; + + const filters = ds.filters ?? []; + const columns: GroupedTableColumn[] = current.columns ?? []; + + const orderByCol = ds.orderBy?.[0]?.column || ""; + const orderByDir = ds.orderBy?.[0]?.direction || "desc"; + + const updateFilter = (idx: number, f: Partial) => { + const next = filters.map((it, i) => (i === idx ? { ...it, ...f } : it)); + patchDataSource({ filters: next }); + }; + const addFilter = () => + patchDataSource({ + filters: [ + ...filters, + { column: "", operator: "=", value_type: "static", value: "" } as OptionFilter, + ], + }); + const removeFilter = (idx: number) => { + const next = filters.filter((_, i) => i !== idx); + patchDataSource({ filters: next.length === 0 ? undefined : next }); + }; + + const updateColumn = (idx: number, c: Partial) => { + const next = columns.map((it, i) => (i === idx ? { ...it, ...c } : it)); + patch({ columns: next }); + }; + const addColumn = () => patch({ columns: [...columns, { column: "" }] }); + const removeColumn = (idx: number) => { + const next = columns.filter((_, i) => i !== idx); + patch({ columns: next.length === 0 ? undefined : next }); + }; + + const { options: tableOptions } = useDbTables(); + + return ( +
+ {/* ── ① 기본 ─────────────────────────── */} + + + patch({ title: v || undefined })} + placeholder="날짜별 근태" + /> + + + patch({ emptyText: v || undefined })} + placeholder="데이터 없음" + /> + + + + {/* ── ② 데이터 소스 ─────────────────────────── */} + + + patchDataSource({ tableName: v || undefined })} + > + + {tableOptions.map((t) => ( + + ))} + + + + + patchDataSource({ + orderBy: v + ? [{ column: v, direction: orderByDir as "asc" | "desc" }] + : undefined, + }) + } + placeholder="attendance_date" + /> + + {orderByCol && ( + + + patchDataSource({ + orderBy: [{ column: orderByCol, direction: v as "asc" | "desc" }], + }) + } + options={[ + { value: "desc", label: "내림차순" }, + { value: "asc", label: "오름차순" }, + ]} + /> + + )} + + patchDataSource({ limit: v ?? 100 })} + min={1} + max={500} + /> + + + + {/* ── ③ 그룹 ─────────────────────────── */} + + + patch({ groupBy: v || undefined })} + placeholder="attendance_date, dept_code, ..." + /> + + + patch({ sortGroups: v as "none" | "asc" | "desc" })} + options={[ + { value: "none", label: "원본 순" }, + { value: "asc", label: "오름차순" }, + { value: "desc", label: "내림차순" }, + ]} + /> + + + + {/* ── ④ 컬럼 ─────────────────────────── */} + 0 ? `(${columns.length})` : "(자동)"}`}> +
+ +
+ {columns.length === 0 ? ( + 컬럼 미지정 — row 의 모든 컬럼 자동 표시 + ) : ( +
+ {columns.map((c, idx) => ( + updateColumn(idx, p)} + onRemove={() => removeColumn(idx)} + /> + ))} +
+ )} +
+ + {/* ── ⑤ 필터 ─────────────────────────── */} + 0 ? `(${filters.length})` : ""}`}> +
+ +
+ {filters.length === 0 ? ( + 필터 없음 — 테이블 전체 row + ) : ( +
+ {filters.map((f, idx) => ( + updateFilter(idx, p)} + onRemove={() => removeFilter(idx)} + tableName={ds.tableName} + /> + ))} +
+ )} +
+
+ ); +}; + +InvGroupedTableConfigPanel.displayName = "InvGroupedTableConfigPanel"; + +function ColumnRow({ + column, + onChange, + onRemove, +}: { + column: GroupedTableColumn; + onChange: (p: Partial) => void; + onRemove: () => void; +}) { + return ( +
+ onChange({ column: e.target.value })} + placeholder="컬럼명" + style={inputBase()} + /> + onChange({ label: e.target.value || undefined })} + placeholder="라벨" + style={inputBase()} + /> + onChange({ width: e.target.value || undefined })} + placeholder="auto" + style={inputBase()} + /> + + +
+ ); +} + +function inputBase(): React.CSSProperties { + return { + height: 22, + padding: "0 6px", + fontSize: 11, + fontFamily: "var(--v5-font-sans)", + background: "var(--cp-surface)", + border: "1px solid var(--cp-border)", + borderRadius: 3, + color: "var(--cp-text)", + outline: "none", + minWidth: 0, + }; +} + +export default InvGroupedTableConfigPanel; diff --git a/frontend/lib/registry/components/grouped-table/index.ts b/frontend/lib/registry/components/grouped-table/index.ts new file mode 100644 index 00000000..a9cb5219 --- /dev/null +++ b/frontend/lib/registry/components/grouped-table/index.ts @@ -0,0 +1,48 @@ +"use client"; + +import { createComponentDefinition } from "../../utils/createComponentDefinition"; +import { ComponentCategory } from "@/types/component"; +import { GroupedTableWrapper } from "./GroupedTableComponent"; +import { InvGroupedTableConfigPanel } from "./InvGroupedTableConfigPanel"; +import type { GroupedTableConfig } from "./types"; + +/** + * GroupedTable — canonical 그룹 테이블 (Phase G.3.1) + * + * `groupBy` 컬럼으로 row 를 묶어 그룹 헤더 + 안쪽 row 로 표시. 근태 / 일자별 / + * 부서별 view 같이 한 화면에서 그룹화된 결과를 보고 싶을 때. + */ + +const DEFAULT_CONFIG: Partial = { + sortGroups: "none", +}; + +export const GroupedTableDefinition = createComponentDefinition({ + id: "grouped-table", + name: "그룹 테이블", + name_eng: "Grouped Table", + description: "row 들을 그룹 키로 묶어 헤더 + 안쪽 row 로 두 단계 표 렌더", + category: ComponentCategory.DATA, + web_type: "text", + component: GroupedTableWrapper, + default_config: DEFAULT_CONFIG as Record, + default_size: { width: 540, height: 300 }, + config_panel: InvGroupedTableConfigPanel, + icon: "Table2", + tags: ["그룹", "테이블", "table", "group", "rows"], + version: "1.0.0", + author: "INVYONE", + documentation: + "notes/gbpark/2026-05-14-studio-data-view-roadmap.md#phase-g31-progress", + dataPorts: { + inputs: [{ name: "data", type: "rows" }], + }, +}); + +export type { + GroupedTableConfig, + GroupedTableColumn, + GroupedTableDataSource, +} from "./types"; +export { GroupedTableComponent, GroupedTableWrapper } from "./GroupedTableComponent"; +export { InvGroupedTableConfigPanel } from "./InvGroupedTableConfigPanel"; diff --git a/frontend/lib/registry/components/grouped-table/types.ts b/frontend/lib/registry/components/grouped-table/types.ts new file mode 100644 index 00000000..cdfb75fc --- /dev/null +++ b/frontend/lib/registry/components/grouped-table/types.ts @@ -0,0 +1,44 @@ +"use client"; + +import type { ComponentConfig } from "@/types/component"; +import type { OptionFilter } from "@/lib/registry/components/input/use-option-loader"; +import type { SelectRowsOrderBy } from "@/lib/api/stats"; + +/** + * canonical grouped-table 설정 타입 (Phase G.3.1) + * + * 같은 테이블의 row 들을 클라이언트 측에서 `groupBy` 로 묶어, 그룹 헤더 row + 안쪽 + * child row 들의 두 단계 표로 렌더. 근태 / 일자별 / 부서별 / 카테고리별 view 처럼 + * 한 화면에 row 묶음을 보고 싶을 때 사용. + */ + +export interface GroupedTableColumn { + /** 표시할 컬럼 이름 */ + column: string; + /** 헤더 라벨 (없으면 column 그대로) */ + label?: string; + /** 너비 (`100px` 또는 `auto`). 없으면 균등 분배 */ + width?: string; + /** 정렬 (text/number/center) */ + align?: "left" | "right" | "center"; +} + +export interface GroupedTableDataSource { + tableName?: string; + filters?: OptionFilter[]; + orderBy?: SelectRowsOrderBy[]; + limit?: number; +} + +export interface GroupedTableConfig extends ComponentConfig { + title?: string; + dataSource?: GroupedTableDataSource; + /** 그룹 키 컬럼. 같은 값을 가진 row 들이 하나의 헤더 row 아래 묶임 */ + groupBy?: string; + /** 표 컬럼들. 비우면 모든 컬럼 표시 */ + columns?: GroupedTableColumn[]; + /** 그룹 정렬 — 기본 자연 순 (등장 순) */ + sortGroups?: "asc" | "desc" | "none"; + /** 빈 결과 문구 */ + emptyText?: string; +} diff --git a/frontend/lib/registry/components/index.ts b/frontend/lib/registry/components/index.ts index bb0a8a15..ef139319 100644 --- a/frontend/lib/registry/components/index.ts +++ b/frontend/lib/registry/components/index.ts @@ -97,6 +97,9 @@ import "./button/ButtonRenderer"; // v2-button-primary + button-primary + rela import "./search/SearchRenderer"; // v2-table-search-widget + table-search-widget + autocomplete-search-input 흡수 import "./input/InputRenderer"; // 20+ 레거시 입력 컴포넌트 흡수 (옛 V2 입력/선택 포함, Phase D.2 에서 V2 측 폐기) import "./stats/StatsRenderer"; // v2-aggregation-widget + v2-status-count + v2-card-display + legacy 흡수 +import "./chart/ChartRenderer"; // Phase G.3 — canonical 차트 (bar / horizontalBar / line / donut, recharts 기반) +import "./card-list/CardListRenderer"; // Phase G.3.1 — canonical 카드 리스트 +import "./grouped-table/GroupedTableRenderer"; // Phase G.3.1 — canonical 그룹 테이블 // form 컴포넌트는 롤백됨 (2026-04-11): "폼" 은 별도 컴포넌트가 아닌 // 화면 디자이너의 3뷰 탭(목록/등록 팝업/수정 팝업) 구조로 처리할 예정. // 관련: notes/gbpark/2026-04-11-component-unification-plan.md §3.2 diff --git a/frontend/lib/registry/components/input/InputComponent.tsx b/frontend/lib/registry/components/input/InputComponent.tsx index 02274591..9e768198 100644 --- a/frontend/lib/registry/components/input/InputComponent.tsx +++ b/frontend/lib/registry/components/input/InputComponent.tsx @@ -642,8 +642,9 @@ export const InputComponent: React.FC = ({ propagate(v)} - placeholder={placeholder || "태그 입력 후 Enter"} - maxSelect={(componentConfig as any).maxSelect} + placeholder={placeholder && placeholder !== "입력하세요" ? placeholder : "태그 입력 후 Enter"} + maxSelect={(componentConfig as any).maxSelect ?? (componentConfig as any).maxTags} + separator={(componentConfig as any).tagSeparator || ","} disabled={disabled} readonly={readonly} className="border-0 bg-transparent rounded-none" @@ -722,19 +723,69 @@ export const InputComponent: React.FC = ({ ); } case "checkbox": { - // single.boolean 영역. mode=toggle (기본 권장) 이면 TogglePicker, 그 외 단일 체크박스. + // single.boolean 영역. canonical input 이 직접 switch / checkbox / yes-no 버튼을 렌더한다. const cbMode = (componentConfig as any).mode; - if (cbMode === "toggle") { + const boolStyle = (componentConfig as any).boolStyle || (cbMode === "toggle" ? "switch" : "checkbox"); + const trueLabel = (componentConfig as any).trueLabel || "예"; + const falseLabel = (componentConfig as any).falseLabel || "아니오"; + const checked = + value === true || + value === 1 || + (typeof value === "string" && ["true", "y", "yes", "1"].includes(value.toLowerCase())); + const lockEdit = disabled || readonly; + + if (boolStyle === "switch" || cbMode === "toggle") { return ( propagate(v)} + trueLabel={trueLabel} + falseLabel={falseLabel} disabled={disabled} readonly={readonly} className="border-0 bg-transparent rounded-none" /> ); } + if (boolStyle === "yesno") { + const buttonBase: React.CSSProperties = { + minWidth: "52px", + height: "28px", + borderRadius: "6px", + border: "1px solid var(--input-border, rgba(148, 163, 184, 0.35))", + fontSize: "13px", + cursor: lockEdit ? "not-allowed" : "pointer", + opacity: lockEdit ? 0.6 : 1, + }; + return ( +
+ + +
+ ); + } return ( ); } diff --git a/frontend/lib/registry/components/input/select-pickers.tsx b/frontend/lib/registry/components/input/select-pickers.tsx index b58d9648..b51dccea 100644 --- a/frontend/lib/registry/components/input/select-pickers.tsx +++ b/frontend/lib/registry/components/input/select-pickers.tsx @@ -541,12 +541,16 @@ export const TagPicker = React.forwardRef( const tags: string[] = Array.isArray(value) ? value : []; const lockEdit = !!disabled || !!readonly; - const addTag = (tag: string) => { - const trimmed = tag.trim(); - if (!trimmed) return; - if (tags.includes(trimmed)) return; - if (maxSelect && tags.length >= maxSelect) return; - onChange?.([...tags, trimmed]); + const commitTags = (raw: string) => { + const parts = separator ? raw.split(separator) : [raw]; + const next = [...tags]; + for (const part of parts) { + const trimmed = part.trim(); + if (!trimmed || next.includes(trimmed)) continue; + if (maxSelect && next.length >= maxSelect) break; + next.push(trimmed); + } + if (next.length !== tags.length) onChange?.(next); }; const removeTag = (idx: number) => { @@ -557,7 +561,7 @@ export const TagPicker = React.forwardRef( if (e.key === "Enter" || (separator && e.key === separator)) { e.preventDefault(); if (input.trim()) { - addTag(input); + commitTags(input); setInput(""); } } else if (e.key === "Backspace" && input === "" && tags.length > 0) { @@ -570,9 +574,11 @@ export const TagPicker = React.forwardRef( ref={ref} className={cn( "flex h-full w-full flex-wrap items-center gap-1 px-2 py-1 text-sm", + "content-start", lockEdit && "cursor-not-allowed", className, )} + style={{ alignItems: "flex-start" }} > {tags.map((tag, i) => ( ( setInput(e.target.value)} + onChange={(e) => { + const next = e.target.value; + if (separator && next.includes(separator)) { + commitTags(next); + setInput(""); + return; + } + setInput(next); + }} onKeyDown={handleKeyDown} + onBlur={() => { + if (input.trim()) { + commitTags(input); + setInput(""); + } + }} placeholder={tags.length === 0 ? placeholder : ""} - className="min-w-[80px] flex-1 bg-transparent text-sm outline-none" + className="min-w-[80px] flex-1 bg-transparent text-sm leading-6 outline-none" disabled={lockEdit} /> )} diff --git a/frontend/lib/registry/components/stats/InvStatsConfigPanel.tsx b/frontend/lib/registry/components/stats/InvStatsConfigPanel.tsx index ee8ed74f..47ad67a7 100644 --- a/frontend/lib/registry/components/stats/InvStatsConfigPanel.tsx +++ b/frontend/lib/registry/components/stats/InvStatsConfigPanel.tsx @@ -3,18 +3,35 @@ /** * InvStatsConfigPanel — 통합 "통계 카드" (id: stats) cp 톤 설정 패널 * + * Phase G.4.2 — **DB-first KPI editor**. + * + * stats 는 KPI / 집계 컴포넌트다. 본 역할: + * - 테이블 선택 + * - 집계 선택 + * - (필요 시) 집계 컬럼 선택 + * - 필터 적용 + * - 결과 숫자 표시 + * + * 그래서 항목 default 자체가 `{ label, dataSource: { aggregation: "count" } }` 이며, + * fallback value / 변화량 (delta) 는 모두 "고급" 섹션 안의 보조 옵션이다. 메인 흐름에 + * 직접 노출되지 않는다. + * * 흐름: * ① 기본 — 제목 - * ② 데이터 소스 — 테이블 (CPSelect) - * ③ 배치 + 스타일 — orientation (CPSegment) + grid 열수 + style (CPVisualGrid 시각) - * ④ 색상 프리셋 — 4개 swatch grid (한 번 클릭 → items[] 의 색 일괄 적용) - * ⑤ 항목 list — dense + 펼침 (라벨+값 한 줄, 펼치면 icon/색/델타) + * ② 배치 + 스타일 + * ③ 색상 프리셋 + * ④ KPI 항목 list — collapsed row 가 핵심 KPI 메타 (라벨 / 테이블 / 집계 / 필터수) + * · 펼치면: 데이터 → 필터 → 외형 → 고급 (flat CP rows, 카드-속-카드 X) * * Reference: notes/gbpark/2026-04-28-cp-panel-standard.md */ import React, { useState } from "react"; -import { Plus, TrendingUp, TrendingDown, Minus } from "lucide-react"; +import { + Plus, + Filter as FilterIcon, + ChevronRight, +} from "lucide-react"; import { CPSection, CPRow, @@ -25,15 +42,29 @@ import { CPColor, CPVisualGrid, Hint, + SectionLabel, } from "@/components/v2/config-panels/_shared/cp"; import { IconPicker } from "../common/IconPicker"; import { useDbTables } from "../common/useDbTables"; -import { - RowNumberBadge, - RowExpandChevron, - RowDeleteBtn, -} from "../common/row-helpers"; -import type { StatsConfig, StatsItem } from "./types"; +import { RowNumberBadge, RowDeleteBtn } from "../common/row-helpers"; +import { OptionFilterRow } from "../_shared/FilterRow"; +import { ColumnPicker } from "../_shared/ColumnPicker"; +import type { OptionFilter } from "../input/use-option-loader"; +import type { + StatsConfig, + StatsItem, + StatItemDataSource, + StatsAggregation, +} from "./types"; + +const AGGREGATION_OPTS: Array<{ value: StatsAggregation; label: string }> = [ + { value: "count", label: "건수" }, + { value: "distinctCount", label: "고유" }, + { value: "sum", label: "합계" }, + { value: "avg", label: "평균" }, + { value: "min", label: "최소" }, + { value: "max", label: "최대" }, +]; const COLOR_PRESETS = [ { id: "blue", name: "블루", colors: ["#3b82f6", "#60a5fa", "#2563eb", "#1d4ed8"] }, @@ -42,6 +73,11 @@ const COLOR_PRESETS = [ { id: "dark", name: "다크", colors: ["#1e3a5f", "#1e3f28", "#5c3c0a", "#5c1a1a"] }, ]; +const blankItem = (n: number): StatsItem => ({ + label: `항목 ${n}`, + dataSource: { aggregation: "count" }, +}); + export interface InvStatsConfigPanelProps { config?: StatsConfig; onChange?: (config: StatsConfig) => void; @@ -52,13 +88,69 @@ export interface InvStatsConfigPanelProps { onTableChange?: (tableName: string) => void; } +// Phase G.4.2 — KPI 패널 keyframes (한 번만 inject). cp 패널 표준 톤 .18s ease-out. +const KEYFRAMES = ` +@keyframes stats-kpi-slide-down { + from { opacity: 0; transform: translateY(-2px); max-height: 0; } + to { opacity: 1; transform: translateY(0); max-height: 1200px; } +} +@keyframes stats-kpi-slide-up { + from { opacity: 1; transform: translateY(0); max-height: 1200px; } + to { opacity: 0; transform: translateY(-2px); max-height: 0; } +} +@keyframes stats-kpi-fade-in { + from { opacity: 0; transform: translateY(-1px); } + to { opacity: 1; transform: translateY(0); } +} +@keyframes stats-kpi-pop-in { + from { opacity: 0; transform: scale(0.96); } + to { opacity: 1; transform: scale(1); } +}`; + +const SLIDE_DURATION_MS = 180; + +function useKeyframesOnce() { + React.useEffect(() => { + const id = "stats-kpi-keyframes"; + if (typeof document === "undefined" || document.getElementById(id)) return; + const style = document.createElement("style"); + style.id = id; + style.textContent = KEYFRAMES; + document.head.appendChild(style); + }, []); +} + +/** + * useSlideToggle — open 변화 시 닫힘 애니메이션이 끝날 때까지 dom 유지. + * open true → render true (즉시) + * open false → closing true → SLIDE_DURATION 후 render false + */ +function useSlideToggle(open: boolean) { + const [render, setRender] = React.useState(open); + const [closing, setClosing] = React.useState(false); + React.useEffect(() => { + if (open) { + setRender(true); + setClosing(false); + return; + } + if (!render) return; + setClosing(true); + const t = setTimeout(() => { + setRender(false); + setClosing(false); + }, SLIDE_DURATION_MS); + return () => clearTimeout(t); + }, [open, render]); + return { render, closing }; +} + export const InvStatsConfigPanel: React.FC = ({ config, onChange, selectedComponent, - screenTableName, - onTableChange, }) => { + useKeyframesOnce(); const current: StatsConfig = (config as StatsConfig) || (selectedComponent?.config as StatsConfig) || {}; @@ -72,12 +164,7 @@ export const InvStatsConfigPanel: React.FC = ({ }; const addItem = () => { - patch({ - items: [ - ...items, - { label: `항목 ${items.length + 1}`, value: 0 }, - ], - }); + patch({ items: [...items, blankItem(items.length + 1)] }); }; const removeItem = (idx: number) => { @@ -88,7 +175,6 @@ export const InvStatsConfigPanel: React.FC = ({ const orientation = current.orientation || "horizontal"; const styleMode = current.style || "card"; - const sourceTable = current.sourceTable || screenTableName || ""; return (
@@ -103,28 +189,8 @@ export const InvStatsConfigPanel: React.FC = ({ - {/* ── ② 데이터 소스 ─────────────────────────── */} - - - { - onTableChange?.(v); - patch({ sourceTable: v || undefined }); - }} - > - - {tableOptions.map((t) => ( - - ))} - - - - - {/* ── ③ 배치 + 스타일 ─────────────────────────── */} - + {/* ── ② 배치 + 스타일 ─────────────────────────── */} + = ({
- {/* ── ④ 색상 프리셋 ─────────────────────────── */} - -
+ {/* ── ③ 색상 프리셋 ─────────────────────────── */} + +
{COLOR_PRESETS.map((preset) => ( ))}
- 항목이 없으면 4개 자동 생성 + 색 적용. 있으면 기존 항목 색만 일괄 변경. + 항목이 없으면 KPI 4개 자동 생성 + 색 적용. 있으면 기존 항목 색만 일괄 변경.
- {/* ── ⑤ 항목 list ─────────────────────────── */} - -
+ {/* ── ④ KPI 항목 ─────────────────────────── */} + +
+
+ {filterCount === 0 ? ( + 필터 없음 — 테이블 전체 집계 + ) : ( +
+ {filters.map((f, i) => ( + updateFilter(i, p)} + onRemove={() => removeFilter(i)} + tableName={ds.tableName} + /> + ))} +
+ )} + + {/* ── 외형 ───────────────────────────── */} + onChange({ color: v || undefined })} /> - + + {/* ── 고급 ───────────────────────────── */} + +
+ )} +
+ ); +} + +// ─────────────────────────────────────────────────────── +// KpiMetaSegment — CPSegment 톤의 메타 요약 (테이블 | 집계 | 컬럼 | 필터) +// 한 컨테이너 + vertical divider. 클릭 시 expand 토글. +// ─────────────────────────────────────────────────────── +function KpiMetaSegment({ + tableValue, + tableLabel: tableLabelProp, + aggregationLabel, + columnName, + filterCount, + expanded, + onClick, +}: { + /** 원본 table 이름 (영문 / 식별자) — tooltip 용 */ + tableValue?: string; + /** 표시용 라벨 (사용자정보 등). 없으면 value 그대로 또는 "테이블 미설정" */ + tableLabel?: string; + aggregationLabel: string; + columnName?: string; + filterCount: number; + expanded?: boolean; + onClick: () => void; +}) { + const tableMissing = !tableValue; + const tableLabel = tableLabelProp ?? tableValue ?? "테이블 미설정"; + + const cells: Array<{ + key: string; + content: React.ReactNode; + color: string; + weight: number; + italic?: boolean; + }> = [ + { + key: "table", + content: tableLabel, + color: tableMissing ? "var(--cp-text-muted)" : "var(--cp-text)", + weight: tableMissing ? 500 : 600, + italic: tableMissing, + }, + { + key: "agg", + content: aggregationLabel, + color: "hsl(var(--primary))", + weight: 700, + }, + ]; + if (columnName) { + cells.push({ + key: "col", + content: columnName, + color: "var(--cp-text-sec)", + weight: 500, + }); + } + cells.push({ + key: "filter", + content: ( + + + {filterCount} + + ), + color: + filterCount > 0 ? "hsl(var(--primary))" : "var(--cp-text-muted)", + weight: filterCount > 0 ? 700 : 500, + }); + + return ( + + ); +} + +// ─────────────────────────────────────────────────────── +// AdvancedSection — fallback value + delta (collapsible, default closed) +// ─────────────────────────────────────────────────────── +function AdvancedSection({ + item, + onChange, +}: { + item: StatsItem; + onChange: (p: Partial) => void; +}) { + const hasFallback = item.value !== undefined && item.value !== ""; + const [open, setOpen] = useState(hasFallback); + const advAnim = useSlideToggle(open); + + return ( +
+ + {advAnim.render && ( +
+ onChange({ delta: v || undefined })} - placeholder="+12%" - /> - - - - onChange({ deltaDirection: v as StatsItem["deltaDirection"] }) + onChange({ value: v === "" ? undefined : isNaN(Number(v)) ? v : Number(v) }) } - options={[ - { - value: "up", - label: ( - - 상승 - - ), - }, - { - value: "neutral", - label: ( - - 보통 - - ), - }, - { - value: "down", - label: ( - - 하락 - - ), - }, - ]} + placeholder="비우면 —" />
@@ -538,6 +919,9 @@ function ItemEditRow({ ); } +// ─────────────────────────────────────────────────────── +// inline input/select styles (compact, collapsed row 용) +// ─────────────────────────────────────────────────────── function inputStyle({ mono = false }: { mono?: boolean } = {}): React.CSSProperties { return { height: 22, diff --git a/frontend/lib/registry/components/stats/StatsComponent.tsx b/frontend/lib/registry/components/stats/StatsComponent.tsx index 7082ee88..64fe2fcd 100644 --- a/frontend/lib/registry/components/stats/StatsComponent.tsx +++ b/frontend/lib/registry/components/stats/StatsComponent.tsx @@ -1,16 +1,18 @@ "use client"; import React from "react"; +import * as LucideIcons from "lucide-react"; import { ComponentRendererProps } from "@/types/component"; import { filterDOMProps } from "@/lib/utils/domPropsFilter"; import { StatsConfig, StatsItem, StatsOrientation, StatsStyle } from "./types"; +import { useStatsData } from "./use-stats-data"; /** * Stats — 통합 통계/KPI 카드 컴포넌트 * - * items 배열을 받아 각각을 카드/칩/빅넘버 형태로 렌더. 실제 데이터 집계는 - * 상위 컴포넌트 또는 DataPort 로 받음. Phase B-2 는 **표시 로직만**, 연결은 - * Phase F 에서. + * items 배열을 받아 각각을 카드/칩/빅넘버 형태로 렌더. Phase G.1 부터 각 항목의 + * `dataSource.tableName` 이 있으면 백엔드 `/aggregate` 결과로 값을 채우고, 없으면 + * 기존 정적 `item.value` 를 그대로 사용. 디자인 모드에서는 API 호출 안 함. */ const VALID_ORIENTATIONS: StatsOrientation[] = ["horizontal", "vertical", "grid"]; @@ -51,11 +53,33 @@ export const StatsComponent: React.FC = ({ ...fromProps, } as StatsConfig; + // Phase G.4.2 — DB-first default. 정적 value 박지 않음. dataSource 는 비어 + // 있어도 디자인 모드에서 "—" 로 표시. 운영 모드에서 사용자가 테이블/집계 채우면 + // 자동 동작. const items: StatsItem[] = componentConfig.items ?? [ - { label: "항목 1", value: 0 }, - { label: "항목 2", value: 0 }, - { label: "항목 3", value: 0 }, + { label: "항목 1", dataSource: { aggregation: "count" } }, + { label: "항목 2", dataSource: { aggregation: "count" } }, + { label: "항목 3", dataSource: { aggregation: "count" } }, ]; + + // Phase G.1 — dataSource 가 있는 항목은 백엔드에서 가져온다. + const userContext = React.useMemo( + () => ({ + companyCode: (p.companyCode as string) || undefined, + userId: (p.userId as string) || undefined, + deptCode: (p.deptCode as string) || undefined, + userName: (p.userName as string) || undefined, + }), + // 단순 값들이라 deps 직접 나열 + [p.companyCode, p.userId, p.deptCode, p.userName], + ); + const statsData = useStatsData(items, { + isDesignMode, + formData: (p.formData as Record) || undefined, + userContext, + refreshKey: (p.refreshKey as number | string | undefined) || undefined, + }); + const orientation: StatsOrientation = componentConfig.orientation ?? "horizontal"; const statsStyle: StatsStyle = componentConfig.style ?? "card"; // columns 미지정 시 undefined → auto-fit 이 폭에 맞춰 자동 결정 (5개면 5열 등). @@ -190,8 +214,69 @@ export const StatsComponent: React.FC = ({ } = props as any; /* eslint-enable @typescript-eslint/no-unused-vars */ + const formatValue = (raw: unknown): string => { + if (raw === null || raw === undefined || raw === "") return "—"; + if (typeof raw === "number") { + // 정수면 그대로, 소수는 소숫점 둘째자리까지 컷 + if (Number.isInteger(raw)) return raw.toLocaleString(); + return raw.toLocaleString(undefined, { maximumFractionDigits: 2 }); + } + return String(raw); + }; + + const renderIcon = ( + iconName: string | undefined, + size: number, + color: string, + ): React.ReactNode => { + if (!iconName) return null; + const Icon = (LucideIcons as unknown as Record)[ + iconName + ]; + if (Icon) { + return React.createElement(Icon, { + size, + strokeWidth: 2, + style: { color, flexShrink: 0 }, + "aria-hidden": true, + }); + } + return ( + + ); + }; + const renderItem = (item: StatsItem, idx: number) => { - const displayValue = item.value ?? 0; + const key = item.id != null ? String(item.id) : String(idx); + const fetched = statsData[key]; + const hasDataSource = !!item.dataSource?.tableName; + + let displayValue: React.ReactNode; + let stateNote: { text: string; tone: "muted" | "error" } | null = null; + + if (hasDataSource) { + if (fetched?.loading) { + displayValue = "…"; + stateNote = { text: "로딩 중", tone: "muted" }; + } else if (fetched?.error) { + displayValue = "—"; + stateNote = { text: fetched.error, tone: "error" }; + } else if (fetched?.value !== undefined) { + displayValue = formatValue(fetched.value); + } else { + // fetch 아직 안 됨. fallback 있으면 표시, 아니면 "—" + displayValue = item.value !== undefined ? formatValue(item.value) : "—"; + // 카드 안에 안내 (디자인 진단 도움) + stateNote = { text: "대기 중 / 미실행", tone: "muted" }; + } + } else { + // dataSource 미완성: fallback 또는 dim 안내 + displayValue = item.value !== undefined ? formatValue(item.value) : "—"; + stateNote = { text: "테이블 미설정", tone: "muted" }; + } + const color = item.color ?? "hsl(var(--primary))"; if (statsStyle === "chip") { @@ -208,7 +293,7 @@ export const StatsComponent: React.FC = ({ fontSize: "12px", }} > - {item.icon && {item.icon}} + {renderIcon(item.icon, 13, color)} {item.label} {displayValue}
@@ -237,7 +322,7 @@ export const StatsComponent: React.FC = ({ letterSpacing: "0.05em", }} > - {item.icon && `${item.icon} `} + {renderIcon(item.icon, 12, "hsl(var(--muted-foreground))")} {item.label}
= ({ > {displayValue} + {stateNote && ( + + {stateNote.text} + + )} {item.delta && ( = ({ fontWeight: 600, }} > - {item.icon && `${item.icon} `} + {renderIcon(item.icon, 12, "hsl(var(--muted-foreground))")} {item.label} = ({ > {displayValue} + {stateNote && ( + + {stateNote.text} + + )} {item.delta && ( = { orientation: "horizontal", style: "card", + // Phase G.4.2 — DB-first 기본. 새 stats 컴포넌트는 각 항목이 dataSource 를 가지고 + // 시작. 정적 value 는 박지 않는다 — 디자인 모드에서 자연스럽게 "—" 표시되고 + // 사용자가 테이블/필터를 채우면 운영 모드에서 KPI 가 자동 동작. items: [ - { label: "항목 1", value: 0, icon: "📊" }, - { label: "항목 2", value: 0, icon: "📈" }, - { label: "항목 3", value: 0, icon: "💰" }, + { label: "항목 1", icon: "BarChart3", dataSource: { aggregation: "count" } }, + { label: "항목 2", icon: "TrendingUp", dataSource: { aggregation: "count" } }, + { label: "항목 3", icon: "DollarSign", dataSource: { aggregation: "count" } }, ], }; diff --git a/frontend/lib/registry/components/stats/types.ts b/frontend/lib/registry/components/stats/types.ts index 6e46050c..db9f5230 100644 --- a/frontend/lib/registry/components/stats/types.ts +++ b/frontend/lib/registry/components/stats/types.ts @@ -1,6 +1,7 @@ "use client"; import { ComponentConfig } from "@/types/component"; +import type { OptionFilter } from "@/lib/registry/components/input/use-option-loader"; /** * Stats 컴포넌트 통합 설정 타입 @@ -10,18 +11,41 @@ import { ComponentConfig } from "@/types/component"; * 를 흡수. */ -export type StatsAggregation = "count" | "sum" | "avg" | "min" | "max"; +export type StatsAggregation = + | "count" + | "sum" + | "avg" + | "min" + | "max" + | "distinctCount"; export type StatsOrientation = "horizontal" | "vertical" | "grid"; export type StatsStyle = "card" | "chip" | "bigNumber"; +/** + * StatItemDataSource — 항목 단위 데이터 바인딩 (Phase G.1) + * + * tableName 이 비어 있으면 fetch 를 건너뛰고 정적 `value` 사용. + * `filters` 는 canonical `OptionFilter` 와 동일한 모양 — runtime hook 이 + * `value_type` (`field` / `user`) 를 formData / userContext 로 치환한 후 백엔드에 + * 전달. + */ +export interface StatItemDataSource { + tableName?: string; + aggregation?: StatsAggregation; + columnName?: string; + filters?: OptionFilter[]; +} + export interface StatsItem { + /** 항목 id (안정적 hook 키). 없으면 index 사용 */ + id?: string; /** 라벨 */ label: string; - /** 집계 대상 컬럼 */ + /** 집계 대상 컬럼 (레거시 static 호환 — Phase G.1 부터는 dataSource 권장) */ column?: string; - /** 집계 방식 */ + /** 집계 방식 (레거시 static 호환) */ aggregation?: StatsAggregation; - /** 고정 값 (column 없이 직접 지정) */ + /** 고정 값 (column 없이 직접 지정 — dataSource 결과가 우선) */ value?: string | number; /** 라벨 앞 아이콘 (이모지 또는 lucide 이름) */ icon?: string; @@ -33,6 +57,8 @@ export interface StatsItem { delta?: string; /** 변화 방향: up / down / neutral */ deltaDirection?: "up" | "down" | "neutral"; + /** Phase G.1 — DB 기반 데이터 바인딩 */ + dataSource?: StatItemDataSource; } export interface StatsConfig extends ComponentConfig { diff --git a/frontend/lib/registry/components/stats/use-stats-data.ts b/frontend/lib/registry/components/stats/use-stats-data.ts new file mode 100644 index 00000000..070d5d37 --- /dev/null +++ b/frontend/lib/registry/components/stats/use-stats-data.ts @@ -0,0 +1,259 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { aggregateTableStat } from "@/lib/api/stats"; +import type { OptionFilter } from "@/lib/registry/components/input/use-option-loader"; +import type { StatItemDataSource, StatsItem } from "./types"; + +/** + * use-stats-data — Phase G.1 + G.4.x preview-in-design + * + * Stats 항목 중 `dataSource.tableName` 이 설정된 것만 백엔드 `/aggregate` 로 값을 + * 가져온다. **디자인 모드에서도 미리보기 fetch** 함 (사용자가 카드 설정 즉시 결과 + * 확인할 수 있도록). 다만 typing 폭주 방지를 위해 350ms debounce. + * + * - 키: `item.id ?? String(idx)` + * - request key 캐시 (같은 호출이 중복으로 가지 않음) + * - race guard: 새 effect 가 시작되면 이전 결과 무시 + * - 카드 단위 error state: 한 카드 실패가 다른 카드를 죽이지 않음 + * - debounce: 디자인 모드 350ms, 운영 모드 0ms (즉시) + * + * formData / userContext 가 들어오면 `OptionFilter.value_type` 의 `field` / `user` + * 를 실제 값으로 치환한 후 백엔드에 보낸다. + */ + +const DESIGN_DEBOUNCE_MS = 350; + +export interface UseStatsDataOptions { + isDesignMode?: boolean; + formData?: Record; + userContext?: { + companyCode?: string; + userId?: string; + deptCode?: string; + userName?: string; + }; + /** 외부에서 강제 재조회 트리거 (refreshKey 변경 시 모든 항목 다시 fetch) */ + refreshKey?: number | string; +} + +export interface StatsValueState { + value?: number; + error?: string; + loading?: boolean; +} + +export type StatsDataMap = Record; + +type StatsFetchPlanItem = + | { key: string; skip: true; pending?: boolean } + | { + key: string; + skip: false; + reqKey: string; + req: { + tableName: string; + aggregation: NonNullable; + columnName?: string; + filters: Array<{ column: string; operator: string; value?: unknown }>; + }; + }; + +const ITEM_KEY = (item: StatsItem, idx: number): string => + item.id != null ? String(item.id) : String(idx); + +/** + * OptionFilter 의 value_type 을 formData / userContext 로 치환해서 백엔드용 필터로 + * 변환. 치환에 실패한 항목은 (필수 컨텍스트 누락) drop 한다 — 의도와 다른 전체 결과를 + * 내는 것보다 카드 에러가 안전. + */ +function resolveFilters( + filters: OptionFilter[] | undefined, + formData: Record | undefined, + userContext: UseStatsDataOptions["userContext"], +): { resolved: Array<{ column: string; operator: string; value?: unknown }>; ok: boolean } { + if (!filters || filters.length === 0) return { resolved: [], ok: true }; + + const out: Array<{ column: string; operator: string; value?: unknown }> = []; + for (const f of filters) { + if (!f || !f.column) continue; + const op = f.operator ?? "="; + + if (op === "isNull" || op === "isNotNull") { + out.push({ column: f.column, operator: op }); + continue; + } + + let value: unknown = f.value; + const valueType = f.value_type ?? "static"; + if (valueType === "field") { + const ref = f.field_ref; + if (!ref) return { resolved: [], ok: false }; + value = formData?.[ref]; + if (value === undefined) return { resolved: [], ok: false }; + } else if (valueType === "user") { + const uf = f.user_field; + if (!uf) return { resolved: [], ok: false }; + value = userContext?.[uf]; + if (value === undefined || value === "") return { resolved: [], ok: false }; + } + + // value 가 명시적으로 null / undefined 면 비교 필터는 의미 없음 → skip + // (isNull / isNotNull 은 위에서 이미 처리됨) + if (value === undefined || value === null) { + continue; + } + + out.push({ column: f.column, operator: op, value }); + } + return { resolved: out, ok: true }; +} + +/** + * 항목의 fetch request key 를 만든다 — 같은 키면 캐시. + */ +function buildRequestKey( + ds: StatItemDataSource, + filters: Array<{ column: string; operator: string; value?: unknown }>, +): string { + return JSON.stringify({ + t: ds.tableName, + a: ds.aggregation ?? "count", + c: ds.columnName ?? "", + f: filters, + }); +} + +/** + * dataSource 가 fetch 가능한 상태인지 (tableName + 필수 columnName) 검사. + */ +function isDataSourceReady(ds: StatItemDataSource | undefined): boolean { + if (!ds || !ds.tableName) return false; + const agg = ds.aggregation ?? "count"; + if (agg === "count") return true; + return !!ds.columnName; +} + +export function useStatsData( + items: StatsItem[], + opts: UseStatsDataOptions = {}, +): StatsDataMap { + const { isDesignMode, formData, userContext, refreshKey } = opts; + const [state, setState] = useState({}); + + // 요청 키 직렬화 — 항목 / formData / userContext / refreshKey 가 의미 있게 바뀔 + // 때만 effect 재실행. items 객체 동등성 X. + const planSig = useMemo(() => { + const plan: StatsFetchPlanItem[] = items.map((it, idx) => { + const key = ITEM_KEY(it, idx); + if (!isDataSourceReady(it.dataSource)) return { key, skip: true }; + const { resolved, ok } = resolveFilters( + it.dataSource!.filters, + formData, + userContext, + ); + if (!ok) return { key, skip: true, pending: true }; + const reqKey = buildRequestKey(it.dataSource!, resolved); + return { + key, + skip: false, + reqKey, + req: { + tableName: it.dataSource!.tableName!, + aggregation: it.dataSource!.aggregation ?? "count", + columnName: it.dataSource!.columnName, + filters: resolved, + }, + }; + }); + return JSON.stringify(plan); + }, [items, formData, userContext]); + + const cacheRef = useRef>(new Map()); + const versionRef = useRef(0); + + useEffect(() => { + const plan: StatsFetchPlanItem[] = JSON.parse(planSig); + if (plan.every((p) => p.skip)) { + setState((prev) => (Object.keys(prev).length > 0 ? {} : prev)); + return; + } + + versionRef.current += 1; + const myVersion = versionRef.current; + let cancelled = false; + + if (refreshKey !== undefined) { + cacheRef.current.clear(); + } + + // loading 표시 (skip 항목 제외, 캐시 hit 은 즉시 값으로) + setState((prev) => { + const next: StatsDataMap = { ...prev }; + let changed = false; + for (const p of plan) { + if (p.skip) { + if (p.pending && next[p.key]) { + delete next[p.key]; + changed = true; + } + continue; + } + const cached = cacheRef.current.get(p.reqKey); + if (cached !== undefined && refreshKey === undefined) { + if (next[p.key]?.value !== cached || next[p.key]?.loading || next[p.key]?.error) { + next[p.key] = { value: cached }; + changed = true; + } + } else { + if (!next[p.key]?.loading || next[p.key]?.error) { + next[p.key] = { ...next[p.key], loading: true, error: undefined }; + changed = true; + } + } + } + return changed ? next : prev; + }); + + // 디자인 모드는 typing 폭주 방지용 debounce. 운영 모드는 즉시. + const debounceMs = isDesignMode ? DESIGN_DEBOUNCE_MS : 0; + const timer = setTimeout(() => { + (async () => { + for (const p of plan) { + if (p.skip) continue; + const cached = cacheRef.current.get(p.reqKey); + if (cached !== undefined && refreshKey === undefined) continue; + + try { + const { value } = await aggregateTableStat(p.req.tableName, { + aggregation: p.req.aggregation, + columnName: p.req.columnName, + filters: p.req.filters as any, + }); + if (cancelled || versionRef.current !== myVersion) return; + cacheRef.current.set(p.reqKey, value); + setState((prev) => ({ + ...prev, + [p.key]: { value, loading: false }, + })); + } catch (err: any) { + if (cancelled || versionRef.current !== myVersion) return; + const msg = err?.response?.data?.message || err?.message || "집계 실패"; + setState((prev) => ({ + ...prev, + [p.key]: { ...prev[p.key], loading: false, error: String(msg) }, + })); + } + } + })(); + }, debounceMs); + + return () => { + cancelled = true; + clearTimeout(timer); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [planSig, isDesignMode, refreshKey]); + + return state; +} diff --git a/frontend/lib/utils/getComponentConfigPanel.tsx b/frontend/lib/utils/getComponentConfigPanel.tsx index bcab8cac..94aee9ad 100644 --- a/frontend/lib/utils/getComponentConfigPanel.tsx +++ b/frontend/lib/utils/getComponentConfigPanel.tsx @@ -116,8 +116,12 @@ const CONFIG_PANEL_MAP: Record Promise> = { "stats-card": () => import("@/components/screen/config-panels/StatsCardConfigPanel"), "progress": () => import("@/components/screen/config-panels/ProgressBarConfigPanel"), "progress-bar": () => import("@/components/screen/config-panels/ProgressBarConfigPanel"), - "chart": () => import("@/components/screen/config-panels/ChartConfigPanel"), - "chart-basic": () => import("@/components/screen/config-panels/ChartConfigPanel"), + // Phase G.3 — canonical chart (recharts 기반). 옛 placeholder ChartConfigPanel 은 미사용. + "chart": () => import("@/lib/registry/components/chart/InvChartConfigPanel"), + "chart-basic": () => import("@/lib/registry/components/chart/InvChartConfigPanel"), + // Phase G.3.1 — canonical card-list / grouped-table + "card-list": () => import("@/lib/registry/components/card-list/InvCardListConfigPanel"), + "grouped-table": () => import("@/lib/registry/components/grouped-table/InvGroupedTableConfigPanel"), "alert": () => import("@/components/screen/config-panels/AlertConfigPanel"), "alert-info": () => import("@/components/screen/config-panels/AlertConfigPanel"), "badge": () => import("@/components/screen/config-panels/BadgeConfigPanel"), diff --git a/notes/gbpark/2026-05-14-container-twin/index.html b/notes/gbpark/2026-05-14-container-twin/index.html new file mode 100644 index 00000000..da5be2ee --- /dev/null +++ b/notes/gbpark/2026-05-14-container-twin/index.html @@ -0,0 +1,1519 @@ + + + + + +컨테이너 디지털트윈 — 적재 시뮬레이터 + + + +
+ + +
+
+ + + + +
+
+
마우스: 회전 · 휠: 확대 · Shift+드래그: 이동
+ +
+ +
+
+
+
파레트 / 박스 수
+
0pal
+
0개 박스 · 파레트당 0 ()
+
+
+
바닥 면적 활용
+
0%
+
+
+
+
부피 활용
+
0%
+
0 / 0
+
+
+
+
무게 (Max 대비)
+
0%
+
0 / 0 kg
+
+
+
+
+
+ + + + + diff --git a/notes/gbpark/2026-05-14-invyone-intro/agent-demo.html b/notes/gbpark/2026-05-14-invyone-intro/agent-demo.html new file mode 100644 index 00000000..4dd6a71d --- /dev/null +++ b/notes/gbpark/2026-05-14-invyone-intro/agent-demo.html @@ -0,0 +1,1984 @@ + + + + + INVYONE Agent — Demo + + + + + +
+
Agent Demo
+ + + +
+ + +
+ +
+ +
+
+
대화 — 영업본부 · 김지원
+
+
INVYONE LLM · ERP-Connected
+
+
+
로그
+
+
+ +
+ + + + +
+
+
🏢 영업본부
+ / +
대화 시작 전
+
+ + 최근 동기화 · 12초 전 +
+
+
+
+
+

무엇을 도와드릴까요?

+

자연어로 물어보면 ERP 데이터를 직접 보고 답해드려요.

+
+
+
+
+ +
+ + + +
+
✓ 완료
+
+ + + + diff --git a/notes/gbpark/2026-05-14-invyone-intro/index.html b/notes/gbpark/2026-05-14-invyone-intro/index.html new file mode 100644 index 00000000..0e27ea64 --- /dev/null +++ b/notes/gbpark/2026-05-14-invyone-intro/index.html @@ -0,0 +1,747 @@ + + + + + Invyone — No-Code · Build Anything + + + + + + +
+
Animations
+ + + + + + + +
+ + +
+
+ +
+ + NO-CODE · BUILD ANYTHING +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + + + + + + + + +
+
Invyone
+
N O - C O D E
+
+ + +
+
+
PTW/LOTO
+
+
+
+
KPI/Report
+
+
+
+
HRM
+
+
+
+
Workflow
+
+ + + +
+
+
MES
+
생산관리
+
+ +
+
+
Inventory
+
재고관리
+
+ +
+
+
PLM
+
제품수명
+
+ +
+
+
AI Agents
+
자동화
+
+ +
+
+
EHS
+
환경안전
+
+ +
+
+
ERP
+
전사자원
+
+ +
+
+
WMS
+
창고관리
+
+ +
+
+
QMS
+
품질관리
+
+ +
+
+
SCM
+
공급망
+
+ +
+
+
CRM
+
고객관리
+
+ +
+
+
SCADA
+
공정감시
+
+ + +
+ + + Build Anything + · + 원하는 대로 자유롭게 +
+
+ + + + + diff --git a/notes/gbpark/2026-05-14-invyone-intro/studio-demo.html b/notes/gbpark/2026-05-14-invyone-intro/studio-demo.html new file mode 100644 index 00000000..46fb638d --- /dev/null +++ b/notes/gbpark/2026-05-14-invyone-intro/studio-demo.html @@ -0,0 +1,1611 @@ + + + + + INVYONE Studio — Demo + + + + + +
+
Studio Demo
+ + + +
+ + +
+ +
+ +
+
+
+
≡ 목록 화면6
+
⊕ 등록 팝업12
+
✎ 수정 팝업4
+
+
테스트 템플릿
+
+
🏷
+
+
👁
+
🌐 다국어 생성
+
⚙ 다국어 설정
+ +
+
+ +
+ + + + +
+
+ 🖥 Full HD (1920×1080) • 1920×1080 +
+
+
+
+
👥 전체 사원
142
+
재직 인원
128
+
신규 입사
8
+
부서 수
12
+
+ + + +
+
+ + 데이터 소스를 선택해주세요 +
+
+
사번
+
사용자ID
+
사용자이름
+
부서이름
+
상태
+
+
+
+
+
+
+ + + +
+ + +
+ + + +
+
✓ 저장되었습니다
+ + + +
+ + + + diff --git a/notes/gbpark/2026-05-14-studio-data-view-roadmap.md b/notes/gbpark/2026-05-14-studio-data-view-roadmap.md new file mode 100644 index 00000000..92739ccf --- /dev/null +++ b/notes/gbpark/2026-05-14-studio-data-view-roadmap.md @@ -0,0 +1,1611 @@ +# 2026-05-14 INV Studio Data View Roadmap + +## 목적 + +INV Studio 에서 실제 운영 화면을 만들 수 있도록, 입력 필드 통합 다음 단계의 기준을 정리한다. + +현재 목표는 특정 "인사 관리" 화면을 하드코딩하는 것이 아니다. 인사 관리 화면은 검증용 대표 예시이고, 최종 목표는 어떤 업무 도메인에서도 재사용 가능한 범용 화면 빌더 기능이다. + +대표 예시: + +- 상단 통계 카드: 전체 사원, 재직 중, 휴직 중, 겸직 현황 +- 탭 1: 사원 목록 +- 탭 2: 부서별 현황 +- 탭 3: 근태 현황 + +이 화면을 스튜디오 설정만으로 만들 수 있어야 한다. + +## 현재 원칙 + +1. 기존 V2/VEX 입력 경로를 되살리지 않는다. +2. 새 솔루션 개발 기준으로 간다. DB layout JSON 마이그레이션 SQL 을 작성하지 않는다. +3. FieldConfig / DataPort 계약은 유지한다. 축소하거나 다시 설계하지 않는다. +4. 인사 관리 전용 하드코딩을 금지한다. +5. 화면 구성 요소는 범용 설정으로 동작해야 한다. +6. 통계 카드, 탭, 테이블, 차트, 리스트는 모두 실제 테이블 데이터를 읽어 렌더링해야 한다. + +## 현재 상태 + +### Canonical Input + +입력 타입은 대부분 canonical `input` / InvInput 경로로 통합된 상태로 본다. + +완료된 범위: + +- text / number / date / datetime / time / daterange / textarea +- single choice / multi choice +- static list / code / category / entity code-name +- checkbox list / swap / tag input +- boolean switch / checkbox / yes-no button +- mask / slider / color +- file / image + +남은 작업 성격: + +- 새 구조 작업보다는 운영 화면에서 이상 동작과 잔여 문구를 잡는 단계 +- 전체 `tsc` 는 기존 camelCase / snake_case 전역 오류 때문에 실패 중 +- 변경 파일 기준 신규 오류 여부를 필터링해서 확인하는 방식으로 진행 + +## 다음 큰 목표 + +입력필드 다음 단계는 다음 두 축이다. + +1. 데이터 바인딩 가능한 통계 카드 +2. 탭별로 다른 데이터 뷰를 가질 수 있는 탭 시스템 + +이 두 기능이 되어야 인사 관리 예시 화면을 만들 수 있다. + +## 1. 데이터 바인딩 통계 카드 + +현재 필요한 것은 고정 숫자 카드가 아니라, 테이블 값을 계산해서 표시하는 카드다. + +예시: + +- 전체 사원: `user_info` count +- 재직 중: `user_info` count where `status = '재직'` +- 휴직 중: `user_info` count where `status = '휴직'` +- 겸직 현황: `user_dept_role` count or distinct employee count + +### 필요한 설정 + +통계 카드 컴포넌트는 최소한 다음 설정을 가져야 한다. + +```ts +type StatCardItem = { + id: string; + label: string; + tableName: string; + aggregation: "count" | "sum" | "avg" | "min" | "max" | "distinctCount"; + columnName?: string; + filters?: Array<{ + column: string; + operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "in" | "notIn" | "isNull" | "isNotNull" | "like"; + valueType: "static" | "field" | "user"; + value?: unknown; + fieldRef?: string; + userField?: "companyCode" | "userId" | "deptCode" | "userName"; + }>; + subText?: string; + subTextMode?: "static" | "ratio" | "template"; + icon?: string; + color?: string; +}; +``` + +### 런타임 요구 + +- 디자인 모드에서는 API 폭주를 막고 preview 값 또는 skeleton 을 보여준다. +- 운영 모드에서는 실제 데이터로 집계한다. +- 여러 카드가 같은 테이블을 보더라도 요청을 묶거나 캐시해야 한다. +- 실패 시 화면 전체가 죽지 않고 해당 카드만 fallback 표시한다. + +## 2. 탭별 데이터 뷰 시스템 + +인사 관리 화면에서 `사원 목록`, `부서별 현황`, `근태 현황`은 단순히 같은 테이블의 필터 탭이 아니다. 탭마다 화면 구성이 다르다. + +### 탭별 차이 + +`사원 목록` + +- 검색 필터 +- 사원 테이블 +- 부서 등록 / 사원 등록 버튼 +- 선택 체크박스 컬럼 + +`부서별 현황` + +- 왼쪽 부서 카드 목록 +- 부서 수정 / 삭제 액션 +- 오른쪽 부서별 인원 막대 차트 +- 직급별 분포 도넛 차트 + +`근태 현황` + +- 기간 / 부서 / 사원 / 상태 필터 +- 날짜별 그룹 row +- 근태 테이블 + +따라서 필요한 것은 단순 Tabs UI 가 아니라, 탭마다 독립적인 view config 를 가지는 범용 `Tabbed Data View` 이다. + +### 필요한 설정 + +```ts +type TabbedViewConfig = { + tabs: Array<{ + id: string; + label: string; + icon?: string; + viewType: "table" | "split-list-chart" | "grouped-table" | "custom"; + dataSources?: DataSourceConfig[]; + filters?: FilterConfig[]; + components?: ViewComponentConfig[]; + }>; + defaultTabId?: string; +}; +``` + +`components` 는 탭 내부에 배치되는 범용 뷰 구성 요소다. + +예: + +- searchFilter +- dataTable +- cardList +- barChart +- donutChart +- actionButtons +- groupedRows + +## 3. 데이터 소스 공통 모델 + +통계 카드, 테이블, 차트, 리스트가 각각 따로 데이터를 가져오면 중복과 버그가 늘어난다. 공통 DataSource 모델이 필요하다. + +```ts +type DataSourceConfig = { + id: string; + tableName: string; + alias?: string; + columns?: string[]; + filters?: FilterConfig[]; + joins?: JoinConfig[]; + groupBy?: string[]; + orderBy?: Array<{ column: string; direction: "asc" | "desc" }>; + limit?: number; +}; +``` + +원칙: + +- 하나의 탭은 여러 DataSource 를 가질 수 있다. +- 차트와 테이블은 같은 DataSource 를 공유할 수 있다. +- 필터 컴포넌트는 같은 탭의 DataSource 에 값을 주입할 수 있어야 한다. +- DataPort 와 충돌하지 않게, 화면 내 데이터 흐름은 기존 계약 위에서 확장한다. + +## 4. 구현 단계 + +### Phase G.0 - 현재 컴포넌트 인벤토리 + +목표: + +- 현재 stats / table / tabs / chart / container 관련 컴포넌트와 registry 경로 조사 +- 옛 V2 경로와 canonical 경로 구분 +- 어떤 컴포넌트를 흡수하고 어떤 컴포넌트를 재사용할지 결정 + +산출물: + +- 관련 파일 목록 +- 현재 런타임 경로 +- 폐기 대상 / 유지 대상 목록 + +### Phase G.1 - Data-bound Stats + +목표: + +- 통계 카드가 실제 테이블 aggregation 결과를 표시하도록 구현 +- count / sum / avg / distinctCount 우선 지원 +- static mock 값 의존 제거 + +범위: + +- stats runtime loader +- stats config panel +- filter resolver 재사용 +- 디자인 모드 API 가드 + +검증 기준: + +- `user_info` 전체 count 표시 +- `status = 재직` count 표시 +- 필터 변경 시 값 갱신 +- API 실패 시 카드 단위 fallback + +### Phase G.2 - Tabbed Data View + +목표: + +- 탭별로 다른 view config 를 저장하고 렌더링 +- 탭마다 다른 DataSource / filter / component layout 허용 + +범위: + +- tabs runtime +- tabs config panel +- default tab / active tab 처리 +- 탭별 hidden/render guard + +검증 기준: + +- `사원 목록`, `부서별 현황`, `근태 현황` 3탭 구성 가능 +- 탭 전환 시 각 탭의 다른 뷰가 표시 +- 탭별 설정이 서로 섞이지 않음 + +### Phase G.3 - View Components + +목표: + +- 탭 안에서 사용할 범용 뷰 컴포넌트를 정리 + +우선순위: + +1. dataTable +2. searchFilter +3. cardList +4. barChart +5. donutChart +6. groupedTable + +검증 기준: + +- 사원 목록 테이블 구성 가능 +- 부서 카드 목록 구성 가능 +- 부서별 인원 막대 차트 구성 가능 +- 직급별 분포 도넛 차트 구성 가능 +- 근태 날짜 그룹 테이블 구성 가능 + +### Phase G.4 - 인사 관리 화면 검증 + +목표: + +- 범용 설정만으로 인사 관리 예시 화면을 재현한다. + +검증 화면: + +- 상단 통계 카드 4개 +- 사원 목록 탭 +- 부서별 현황 탭 +- 근태 현황 탭 + +중요: + +- 화면 이름과 예시 데이터는 인사 관리여도 구현은 범용이어야 한다. +- 인사 관리 전용 if / hardcode 금지. + +### Phase G.5 - Legacy Cleanup + +목표: + +- stats / tabs / old table view 관련 옛 경로가 남아 있으면 제거 +- 단, 실제 재사용 가능한 shared util 은 유지 + +검증: + +- 새 생성 경로가 canonical component 만 사용 +- 옛 V2/VEX id 로 신규 생성되지 않음 +- 기존 FieldConfig / DataPort 계약 영향 0건 + +## 금지 사항 + +- `v2-input`, `v2-select` 재도입 금지 +- old file/media 컴포넌트 부활 금지 +- `EntityPicker` 를 canonical input 에 재도입 금지 +- 인사 관리 전용 hardcode 금지 +- DB layout JSON migration SQL 금지 +- FieldConfig / DataPort 축소 금지 +- 화면을 단순 mock 정적 값으로 마무리 금지 + +## 다음 작업 프롬프트 기준 + +다음 작업자는 먼저 이 문서와 아래 문서를 읽고 시작한다. + +- `notes/gbpark/2026-05-14-studio-data-view-roadmap.md` +- `notes/gbpark/2026-05-12-codex-handoff-input-canonical.md` +- 필요 시 `notes/gbpark/2026-05-08-input-canonical-migration.md` + +다음 작업 시작점은 Phase G.0 이다. + +먼저 현재 repo 의 stats / tabs / table / chart / container 관련 컴포넌트와 registry 경로를 조사하고, 새 범용 Data-bound Stats / Tabbed Data View 를 어디에 붙이는 것이 가장 안전한지 제안한다. + +코드 변경은 조사 결과를 보고 Phase G.1 부터 시작한다. + +--- + +## Phase G.0 Inventory Results + +조사일: 2026-05-14. 브랜치 `gbpark-node`. 코드 변경 없음 (read-only). + +### 0. 검증 사전 통과 + +``` +git diff --check PASS +rg "v2-input|v2-select|V2InputRenderer|V2SelectRenderer" 0 matches +rg "EntityPicker|entity-picker|EntitySearchModal" 0 matches (input + InvFieldConfigPanel) +rg 'componentType: "v2-file-upload"|file: "v2-file-upload"|image: "v2-file-upload"|img: "v2-file-upload"' 0 matches +``` + +canonical input cleanup 상태 그대로 유지됨. G.0 인벤토리에서 추가 정리 필요 없음. + +### 1. Stats / KPI 컴포넌트 + +#### 1.1 canonical `stats` (★ G.1 의 진입점) + +- 폴더: `frontend/lib/registry/components/stats/` + - `StatsComponent.tsx` — card / chip / bigNumber 3 가지 style, horizontal / vertical / grid 3 가지 orientation + - `InvStatsConfigPanel.tsx` — cp 시스템 통합 (title / dataSource / layout / colorPreset / items) + - `StatsRenderer.tsx` — 자가 등록 renderer + - `types.ts` — `StatsConfig`, `StatsItem`, `StatsAggregation` + - `index.ts` — `ComponentDefinition` 선언, `dataPorts.inputs: [{ name: "data", type: "rows" }]` 이미 선언됨 +- ComponentType: `"stats"` +- ConfigPanel routing: `getComponentConfigPanel.tsx` line 21 — `"stats" → InvStatsConfigPanel` +- 현재 상태: **표시 로직만**. `items[].value` 가 static. `sourceTable` 필드는 받지만 fetching 없음. 주석에 "Phase F 에서 연결" 표시 +- alias (`CONFIG_PANEL_ALIAS`): `v2-aggregation-widget → stats`, `v2-status-count → stats`, `aggregation-widget → stats` + +#### 1.2 레거시 (palette 숨김, alias 로 stats 에 흡수) + +| 폴더 | 컴포넌트 ID | 현재 동작 | 분류 | +|---|---|---|---| +| `v2-aggregation-widget/` | `v2-aggregation-widget` | `POST /table-management/tables/{tableName}/data` 호출 + 클라이언트 사이드 집계, V2EventBus 구독 | 레거시 / G.5 폐기 후보 | +| `v2-status-count/` | `v2-status-count` | 같은 endpoint, statusColumn 기준 그룹 카운트 | 레거시 / G.5 폐기 후보 | +| `aggregation-widget/` | `aggregation-widget` | externalData prop 만, 자체 fetch 없음 | 레거시 / G.5 폐기 후보 | + +DataPortBus 가 아니라 V2EventBus 위에서 동작. G.1 에서 재사용은 **개념 (filter / aggregation type)** 만, 코드는 canonical 측에서 새로 작성하는 게 깨끗함. + +### 2. Tabs / Container / Section + +#### 2.1 canonical `container` (★ G.2 의 진입점) + +- 폴더: `frontend/lib/registry/components/container/` + - `ContainerComponent.tsx` — `containerType` 5 가지 (tabs / section / accordion / repeater / conditional) + - `InvContainerConfigPanel.tsx` — cp 시스템 + - `types.ts` — `ContainerConfig`, `ContainerTab { id, label }` + - `index.ts` +- 현재 상태: **모든 모드 스켈레톤**. tabs 모드의 컨텐츠 영역은 literal `"[{activeTab}] 탭 컨텐츠 영역"` 텍스트만 출력. 자식 컴포넌트 렌더 없음 +- `TemplateComponent.children?: TemplateComponent[]` — `frontend/types/invyone-component.ts:856` 에 선언만 되어 있고 어디서도 사용 안 됨 +- state: 로컬 `useState(activeTab)`. 외부 통제 없음. 탭 별 데이터 소스 개념 없음 + +#### 2.2 레거시 (palette 숨김) + +| 폴더 | 컴포넌트 ID | 비고 | +|---|---|---| +| `tabs/` | `tabs` | label 만 가진 단순 탭 | +| `v2-tabs-widget/` | `v2-tabs-widget` | 탭 안에 inline 컴포넌트 배치 (디자인 타임만) | +| `accordion-basic/` | `accordion-basic` | 미완성 | +| `section-card/` `section-paper/` `v2-section-card/` `v2-section-paper/` | section 변형 | container 가 흡수 | +| `split-panel-layout/` `split-panel-layout2/` `v2-split-panel-layout/` | split-panel-layout | **★ 패널 별 tableName / dataFilter / additionalTabs 필드 보유** — G.2 의 per-tab 데이터 모델 레퍼런스 | +| `repeat-container/` `v2-repeat-container/` | repeat | container repeater 가 흡수 | +| `conditional-container/` | conditional | container conditional 이 흡수 | + +`v2-split-panel-layout` 의 `additionalTabs: AdditionalTabConfig[]` 가 "탭 마다 독립 tableName / filter / columns" 패턴을 이미 가지고 있음. 이 구조를 canonical container.tabs 로 옮기는 게 G.2 핵심. + +#### 2.3 ComponentType union 현황 + +`frontend/types/invyone-component.ts:171-182`: + +``` +'table' | 'form' | 'search' | 'button' | 'button-bar' | 'tabs' + | 'split-panel' | 'title' | 'stats' | 'divider' | 'pagination' +``` + +`tabs / split-panel / button-bar / pagination` 은 model-only (palette 숨김). 새 "tabbed data view" 를 별도 ComponentType 으로 추가하지 않고 **canonical `container` 의 `containerType: "tabs"` 를 확장** 하는 게 자연스러움. + +`container` 자체는 `ComponentType` union 에 없음 (string literal 11 종 외). 등록은 ComponentRegistry 측에서. 신규 union 추가는 G.2 단계에서 결정. + +### 3. Table + +- 폴더: `frontend/lib/registry/components/table/` + - `TableComponent.tsx` — 5 display mode (table / split / grouped / pivot / card) + - `InvTableConfigPanel.tsx` + - `useTableData.ts` — pagination / sort / search state hook + - `internals/pivot/` — `PivotChart.tsx` 포함 +- ComponentType: `"table"` +- 데이터 fetch: `entityJoinApi.getTableDataWithJoins(tableName, { page, size, sortBy, sortOrder, search, enableEntityJoin, screenEntityConfigs, dataFilter, excludeFilter })` + - response: `{ data: Record[], total, page, size, totalPages, entityJoinInfo? }` + - backend: `POST /table-management/tables/{tableName}/data` +- alias: `v2-table-list → table`, 그 외 다수 (`split-panel-layout`, `modal-repeater-table` 등) +- G.1 / G.2 에서 신규 작업 없음. 기존 `useTableData` + `entityJoinApi` 재사용. + +### 4. Chart + +#### 4.1 라이브러리 + +- `recharts ^3.2.1` (`frontend/package.json`) + +#### 4.2 현재 차트 구현 + +| 위치 | 용도 | 상태 | +|---|---|---| +| `frontend/lib/registry/components/ChartRenderer.tsx` | studio canvas 차트 컴포넌트 | **placeholder 아이콘만**. legacy `componentRegistry.register("chart")` / `register("chart-basic")` 로만 등록. 실제 차트 렌더 안 함. recharts 사용 X | +| `frontend/components/admin/dashboard/charts/` | admin dashboard 전용 차트 | **실제 recharts 차트**. BarChart / PieChart / LineChart / AreaChart / ComboChart / HorizontalBarChart / StackedBarChart 풀세트. 단, 사용처가 `CanvasElement.tsx`, `DashboardViewer.tsx` (admin 대시보드 시스템) 로 studio runtime 과 완전 별개 | +| `frontend/lib/registry/components/table/internals/pivot/components/PivotChart.tsx` | pivot 테이블 모드 내장 차트 | **실제 recharts 차트** (`PivotBarChart`, `PivotLineChart`, `PivotPieChart`). pivot view 한정 | + +#### 4.3 결론 + +studio canvas 위에 **운영 가능한 차트 컴포넌트는 사실상 없음** (`ChartRenderer.tsx` 는 placeholder). G.3 에서 canonical 차트 컴포넌트를 만들 때: + +- recharts 는 이미 들어와 있음 — 새 dependency 추가 X +- admin dashboard 의 차트 코드 (`BarChart.tsx`, `PieChart.tsx` 등) 가 reference. 다만 admin dashboard 의 `DashboardElement` 모델과 결합되어 있어서 그대로 가져올 수는 없음. studio canonical 시그니처 (data: rows → render) 로 새로 래핑 +- pivot 내부 `PivotChart.tsx` 는 더 가깝게 재사용 가능 (이미 recharts 직접 호출) + +### 5. 데이터 fetching API + +| API 클라이언트 | 위치 | 주요 함수 | response 형태 | 사용처 | +|---|---|---|---|---| +| `entityJoinApi` | `frontend/lib/api/entityJoin.ts` | `getTableDataWithJoins(tableName, opts)` | `{ data, total, page, size, totalPages, entityJoinInfo? }` | canonical `table`, `v2-table-list` | +| `dataApi` | `frontend/lib/api/data.ts` | `getTableData(tableName, opts)`, `getRecordDetail(...)` | `{ data, total, page, size, totalPages }` | `aggregation-widget`, legacy 컴포넌트 | +| `tableTypeApi` | `frontend/lib/api/screen.ts` (line 526+) | `getTables()`, `getTableLabel(...)`, `getColumns(...)`, `getTableData(...)` | 메타 + auto-filter (company_code) | config panel, schema loader | + +G.1 (Data-bound Stats) 는 같은 endpoint 위에 **count / aggregate 변형**이 필요. 두 가지 옵션: + +- A. backend 에 `POST /table-management/tables/{tableName}/aggregate` 신규 추가 (column / aggregation / filters 받음) +- B. 기존 `POST /.../data` 로 가져와 클라이언트 사이드 count — 데이터량 많으면 비효율, v2-aggregation-widget 이 `size: 10000` 으로 이걸 하고 있음 + +권장: 옵션 A. 단, 백엔드 작업이 들어가므로 G.1 1순위 spike 에서 결정. + +### 6. Filter Resolver + +- 위치: `frontend/lib/registry/components/input/use-option-loader.ts` +- 타입: + + ```ts + interface OptionFilter { + column: string; + operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "in" | "notIn" | "like" | "isNull" | "isNotNull"; + value_type?: "static" | "field" | "user"; + value?: unknown; + field_ref?: string; + user_field?: "companyCode" | "userId" | "deptCode" | "userName"; + } + ``` + +- 현재 사용처: canonical `input` (entity / distinct / db source 의 옵션 로딩) +- G.1 에서 그대로 재사용. 신규 stats card 의 `filters` 가 같은 모델로 동작하면 변환 코드 0. +- G.2 의 탭 별 filter 도 동일 모델 사용 가능. + +### 7. DataPort 인프라 + +- 위치: `frontend/lib/dataPort/` + - `DataPortBus.ts` — pub/sub 버스. 채널 = `${componentId}.${portName}`. 화면 단위 인스턴스 + `defaultDataPortBus` 전역 인스턴스 제공 + - `runtime.ts` — `setupConnections(connections, bus)` +- 선언된 컴포넌트: + - `stats` 의 `index.ts` 에 `dataPorts.inputs: [{ name: "data", type: "rows" }]` 선언됨 + - 다른 canonical (table / input / button 등) 은 `dataPorts` 미선언 또는 부분 선언 +- 활성화 상태: **renderer 측 wiring 미완**. `BlockRenderer` / `TemplateRenderer` 어디서도 `setupConnections` 를 호출 안 함. publish / subscribe 호출 0건. `Template.connections` 가 항상 빈 배열로 저장됨 +- 결론: G.1 stats 의 "데이터 흐름" 은 DataPortBus 를 정식 wiring 하지 말고, **props / 직접 fetching** 으로 먼저 작동시키는 게 빠르고 안전. DataPort 활성화는 별도 phase (Phase 3 노드 에디터 도입과 함께) 로 미룸. + +### 8. Registry / Rendering 파이프라인 + +- `Component { type, webType, config }` 이 들어오는 진입점: `frontend/lib/registry/DynamicComponentRenderer.tsx` +- 흐름: + 1. componentType 추출 (`component.componentType` → `component.type` fallback) + 2. `LEGACY_TO_UNIFIED` alias 매핑 (`v2-table-list → table`, `v2-aggregation-widget → stats`, …) + 3. `ComponentRegistry.getComponent(componentType)` 로 ComponentDefinition 조회 + 4. 정의의 `component` 를 React 렌더 +- ConfigPanel 매핑: `frontend/lib/utils/getComponentConfigPanel.tsx` + - `CONFIG_PANEL_MAP` (~100 entries) + `CONFIG_PANEL_ALIAS` (legacy 매핑) + - 사용처: `V2PropertiesPanel.tsx` 의 `DynamicComponentConfigPanel` +- webTypeMapping: `frontend/lib/utils/webTypeMapping.ts` — FieldConfig.type / FieldConfig.format 조합 → `{ componentType, config }`. canonical `input` 이 ~40 종 webType 을 흡수 +- 신규 컴포넌트 추가 시 touch 파일 (canonical `input` 패턴 따라하기): + 1. `frontend/lib/registry/components//` 폴더 + Renderer + ConfigPanel + Component + index.ts + types.ts + 2. `frontend/lib/registry/components/index.ts` 에 `import ".//Renderer";` 추가 + 3. `frontend/lib/utils/getComponentConfigPanel.tsx` 의 `CONFIG_PANEL_MAP` 에 entry + 4. (palette 노출 시) `frontend/components/screen/panels/ComponentsPanel.tsx` 의 `BASIC_IDS` 또는 `ADVANCED_IDS` + 5. (필요 시) `frontend/types/invyone-component.ts` 의 `ComponentType` union 에 추가 + 6. (필요 시) `frontend/lib/utils/webTypeMapping.ts` entry + +G.1 / G.2 는 신규 컴포넌트 폴더가 아니라 기존 canonical (`stats`, `container`) 의 **runtime 확장**이라 touch 가 적음. + +### 9. Stale V2 wording + +확인 결과, runtime 영역의 V2 표현은 **현재 진행 중인 통합 전략의 흔적**이며 stale 이 아님: + +- `frontend/lib/registry/components/index.ts:77` — `// V2 컴포넌트들 (...)` 주석. 흡수 진행 중 표시 +- `getComponentConfigPanel.tsx:133` — `// ── Phase E: v2-* → 통합 컴포넌트 ConfigPanel alias ──` — 의도된 alias 블록 표시 +- `DynamicComponentRenderer.tsx:365, 375` — Phase E 주석. 이력 의도 +- `ComponentsPanel.tsx:99-160` — hidden list 의 `// → canonical input` 화살표 주석. 의도된 migration map +- `V2PropertiesPanel.tsx:225-236` — `const v2ConfigPanels` 하드코딩 매핑 (`v2-layout`, `v2-bom-tree` 등 미마이그된 도메인 패널) + +→ **이번 phase 에서 코드 수정 없음.** 어느 것도 단순 주석 cleanup 으로 끝나지 않고, 실제 마이그레이션 phase 와 묶여서 함께 정리해야 함. + +추가로 폴더 이름 자체 (`frontend/components/v2/`) 도 컨벤션상 마이그레이션 끝난 후 `inv/` 로 일괄 rename 예정 — 이건 별도 phase. G.0 에서 건드리지 않음. + +### 10. 재사용 가능 / 폐기 대상 정리 + +#### 재사용 (G.1 / G.2 에서 의존) + +- `stats` 컴포넌트 (canonical) — items renderer + style/orientation 변형 + cp 컨피그 패널 +- `container` 컴포넌트 (canonical) — tabs 모드의 헤더 + active 상태 — body 만 children renderer 로 교체 +- `entityJoinApi` / `dataApi` — 데이터 fetch +- `useTableData` — pagination/sort/search 상태 관리 패턴 +- `OptionFilter` + `useOptionLoader` filter 모델 — 사용자 컨텍스트 (companyCode 등) placeholder 치환 +- `InvTableConfigPanel` 의 filter / column / dataFilter UI 패턴 — G.2 탭 별 데이터 소스 설정에 차용 +- `v2-split-panel-layout` 의 `additionalTabs` 타입 모양 — G.2 `ContainerTab` 확장 시 reference (코드 복사 아님) +- `PivotChart.tsx` (recharts 사용 패턴) — G.3 canonical 차트 시 reference +- admin dashboard charts (`frontend/components/admin/dashboard/charts/`) — G.3 reference (직접 import X) +- `DataPortBus` — 향후 wiring 시점에 사용. G.1 / G.2 에서는 직접 props 우선 + +#### 향후 폐기 (G.5) + +- `v2-aggregation-widget/` (alias → stats) +- `v2-status-count/` (alias → stats) +- `aggregation-widget/` (alias → stats) +- `tabs/` (legacy) +- `v2-tabs-widget/` (alias → container) +- `accordion-basic/` (legacy 미완) +- `section-card/`, `section-paper/`, `v2-section-card/`, `v2-section-paper/` (alias → container) +- `split-panel-layout/`, `split-panel-layout2/`, `v2-split-panel-layout/` (alias → table / container) +- `repeat-container/`, `v2-repeat-container/` (alias → container) +- `conditional-container/` (alias → container) +- `frontend/lib/registry/components/ChartRenderer.tsx` (placeholder; G.3 canonical 차트가 들어오면 삭제) + +각 폐기는 alias가 비어 있고 DB 에 저장된 컴포넌트 ID 사용처가 0건임을 확인한 후 별도 plan/phase 로 진행. + +### 11. Phase G.1 Data-bound Stats 추천 플랜 + +**원칙**: canonical `stats` 만 건드림. 새 컴포넌트 추가 X. DataPort wiring 도 미루고 직접 fetch. + +1. **데이터 모델 확장** + - `frontend/lib/registry/components/stats/types.ts` 에 `StatItemDataSource` 추가 + - `tableName: string` + - `aggregation: "count" | "sum" | "avg" | "min" | "max" | "distinctCount"` + - `columnName?: string` + - `filters?: OptionFilter[]` — 기존 `OptionFilter` 그대로 재사용 + - `StatsItem` 에 `dataSource?: StatItemDataSource` 필드 추가 (기존 static `value` 와 양립) + +2. **runtime 데이터 로더** + - `frontend/lib/registry/components/stats/useStatsData.ts` (신규) — `items: StatsItem[]` → `Record` 결과 맵 반환 + - 디자인 모드: skeleton / "—" / 0 표시. API 호출 0건 + - 운영 모드: 같은 tableName 끼리 묶어서 단일 요청 또는 in-flight 캐시 + - 실패 시 카드 단위 fallback (전체 화면 무너지지 않음) + +3. **백엔드 endpoint** (★ 결정 필요) + - 옵션 A 권장: `POST /table-management/tables/{tableName}/aggregate` 신설 + - body: `{ aggregation, columnName?, filters: OptionFilter[] }` + - response: `{ value: number }` (count 면 정수, sum/avg 면 숫자) + - 옵션 B fallback: 기존 `/data` endpoint 에서 size 1 + totalCount 만 사용 (count 한정) + - 옵션 결정 전에 backend 측 spike 1회 필요 (별도 task) + +4. **filter resolver** + - `OptionFilter[]` 를 그대로 보내고 백엔드가 처리하는 방향 권장. 클라이언트는 `value_type === "field"` 또는 `"user"` 를 fieldRef / userContext 에서 치환만 함 + - 신규 placeholder 추가 없음 + +5. **InvStatsConfigPanel 확장** + - 기존 cp 시스템 그대로 + - 각 item 에 "데이터 소스" 그룹 추가: 테이블 / 집계 / 컬럼 / 필터 + - 필터는 `OptionFilter` 빌더 — `InvTableConfigPanel` 의 dataFilter 빌더 그대로 차용 (또는 cp `FilterBuilder` 신규) + - static value 와 dataSource 가 둘 다 있으면 dataSource 우선 + +6. **검증 기준 (roadmap §4 G.1)** + - `user_info` 전체 count 카드 한 개 만들기 + - `status = '재직'` count 카드 만들기 + - 필터 column 을 다른 값으로 바꾸면 값 갱신 + - 잘못된 tableName → 카드 한 장만 에러 표시, 나머지 카드 정상 + +7. **하드 제약** + - 인사 / 부서 / 사원 키워드를 컴포넌트 코드에 박지 말 것. 컬럼 / 테이블 이름 전부 config 로 + - `v2-input`, `v2-select`, `EntityPicker` 다시 도입 금지 + - DB layout JSON migration SQL 작성 금지 + - `FieldConfig` 축소 / 재설계 금지 + +8. **추정 작업량**: 백엔드 endpoint + 프론트 hook + 컨피그 패널 확장. 백엔드 spike 1회 포함하여 1.5~2 일 정도. + +### 12. Phase G.2 Tabbed Data View 추천 플랜 + +**원칙**: canonical `container` 의 `containerType: "tabs"` 만 건드림. 새 ComponentType 추가 X. + +1. **타입 확장** + - `frontend/lib/registry/components/container/types.ts` 의 `ContainerTab` 에 추가: + - `dataSources?: DataSourceConfig[]` + - `filters?: FilterConfig[]` (FieldConfig 와 별개. 검색 필터용 — `FieldConfig` 재사용도 검토) + - `components?: TemplateComponent[]` — 탭 내부 배치 컴포넌트들 + - `viewType?: "table" | "split-list-chart" | "grouped-table" | "custom"` (선택) + - `TemplateComponent.children` (이미 선언됨) 의 의미를 "탭 내부 컴포넌트들" 로 확정할지 또는 `ContainerTab.components` 를 별도 사용할지 결정 필요. 추천: `ContainerTab.components` (탭 별 격리가 더 명시적) + +2. **runtime 자식 렌더** + - `ContainerComponent.tsx` 의 `renderTabs()` body 부분의 placeholder 텍스트 제거 + - 현재 active tab 의 `tab.components: TemplateComponent[]` 를 `DynamicComponentRenderer` 로 매핑하여 렌더 + - 디자인 모드: drop zone 표시. 자식 컴포넌트 배치 가능 + - 운영 모드: 직접 렌더 + +3. **탭 별 스코프** + - 현 단계는 단순히 props 로 데이터 흐르게 두기 (DataPort 활성화 미룸) + - 탭 별 `dataSources` 가 있으면 탭 컨테이너 레벨에서 fetch 후 자식에 prop drilling + - 탭 전환 시 비활성 탭 렌더 OFF (lazy) — 데이터 fetch 도 OFF + +4. **active tab 관리** + - 기존 `useState(activeTab)` 유지 + - `defaultTab` config 존중 + - URL query / DataPort 통제는 G.2 범위 밖 + +5. **InvContainerConfigPanel 확장** + - `containerType === "tabs"` 일 때 각 탭 별로 펼침 가능한 cp 그룹: + - 탭 메타 (id / label / icon) + - 데이터 소스 빌더 (선택) + - 자식 컴포넌트 리스트 (선택. 빌더 캔버스에서 드래그하면 자동 채워지므로 패널은 ID 표시만) + +6. **검증 기준 (roadmap §4 G.2)** + - 한 화면에 탭 3개. 각 탭 안에 서로 다른 컴포넌트 (테이블, 카드 목록, 차트 placeholder) 배치 + - 탭 전환 시 그 탭의 컴포넌트만 표시 + - 탭 설정 간 누수 없음 (탭 A 의 filter 가 탭 B 에 영향 X) + +7. **하드 제약** + - 인사 관리 전용 if 분기 금지 + - 옛 `v2-tabs-widget` / `tabs` / `accordion-basic` 부활 금지 + - `Template.connections` 강제 활성화 금지 (별도 phase) + - `FieldConfig` / `DataPort` 축소 금지 + - 하드코딩된 탭 라벨 ("사원 목록" 등) 금지 + +8. **추정 작업량**: 타입 확장 + 자식 렌더 + 컨피그 패널 + 디자인 모드 drop zone. 2~3 일. + +### 13. 다음 단계 추천 + +- 즉시 시작 가능: **Phase G.1** (백엔드 spike 1회 후 본격 구현) +- G.2 는 G.1 끝나고 시작. 두 phase 가 서로 독립이라 병렬도 가능하나 단일 작업자면 직렬이 안전 +- G.3 (차트 / 카드 목록 / grouped table) 은 G.2 끝나고. recharts 기반 신규 canonical 컴포넌트 신설 +- G.4 (인사 관리 화면 검증) 는 G.3 끝나고. 화면 자체는 빌더 설정만으로 생성 +- G.5 (legacy cleanup) 는 가장 마지막. 메모리에 박힌 alias / 폐기 후보 리스트 그대로 진행 + +코드 변경 0건. 본 인벤토리 작업으로 추가된 파일 외에 수정 없음. + +--- + +### Phase G.1 Progress + +작업일: 2026-05-14. 브랜치 `gbpark-node`. + +#### Changed files + +Backend (Java / Spring): +- `backend-spring/src/main/java/com/erp/controller/TableManagementController.java` + - 신규 endpoint `POST /api/table-management/tables/{tableName}/aggregate` +- `backend-spring/src/main/java/com/erp/service/TableManagementService.java` + - 신규 메서드 `aggregateTableData(String tableName, Map options)` + 내부 헬퍼 (`buildAggregateWhere`, `normalizeAggregateFilters`, `toList`, 상수 `AGG_TYPES`, `FILTER_OPS`) + +Frontend (Next / TS): +- `frontend/lib/api/stats.ts` (신규) — `aggregateTableStat(tableName, request)` + `AggregateRequest` / `AggregateResponse` 타입 +- `frontend/lib/registry/components/stats/types.ts` — `StatsAggregation` 에 `distinctCount` 추가, `StatItemDataSource` 신규, `StatsItem.id?`, `StatsItem.dataSource?` 신규 +- `frontend/lib/registry/components/stats/use-stats-data.ts` (신규) — runtime 데이터 로더 hook +- `frontend/lib/registry/components/stats/StatsComponent.tsx` — `useStatsData` 호출 + 항목 단위 `displayValue` 결정 (dataSource fetch 값 우선, fallback `item.value`) +- `frontend/lib/registry/components/stats/InvStatsConfigPanel.tsx` — 항목 expand 영역에 `DataSourceEditor` (테이블 / 집계 / 컬럼 / 필터) + `FilterRow` 추가 + +#### Backend endpoint contract + +``` +POST /api/table-management/tables/{tableName}/aggregate +Content-Type: application/json + +{ + "aggregation": "count" | "sum" | "avg" | "min" | "max" | "distinctCount", + "columnName": "optional_column", + "filters": [ + { "column": "status", "operator": "=", "value": "재직" } + ] +} +``` + +응답 (ApiResponse wrapper): +``` +{ + "success": true, + "data": { "value": 12 }, + "message": "테이블 집계를 성공적으로 조회했습니다." +} +``` + +#### Supported aggregation / filter operators + +| aggregation | columnName | 비고 | +|---|---|---| +| `count` | optional | column 없으면 `COUNT(*)`, 있으면 `COUNT("col")` (NULL 제외) | +| `distinctCount` | required | `COUNT(DISTINCT "col")` | +| `sum`, `avg` | required | `CAST(col AS NUMERIC)` 후 집계. 숫자 호환 값이 들어 있는 컬럼에서 사용 | +| `min`, `max` | required | 컬럼 타입 그대로 | + +| operator | 처리 | +|---|---| +| `=`, `!=`, `>`, `<`, `>=`, `<=` | 단일 `?` 파라미터 | +| `like` | `"col"::text ILIKE '%val%'` | +| `in`, `notIn` | 배열 / 콤마 문자열 → `IN (?, ?, ...)` | +| `isNull`, `isNotNull` | 값 없이 적용 | + +검증: +- 모든 식별자는 기존 `sanitize` (영문/숫자/_ 외 제거) 후 `hasColumn` 으로 컬럼 존재 확인. 없는 컬럼은 자동 drop +- 잘못된 aggregation / 누락된 column / 없는 테이블 → `IllegalArgumentException` → 400 에러 +- 값은 `JdbcTemplate` 의 `?` 파라미터 바인딩만 사용. 식별자 자리에 raw input 들어가지 않음 +- `filters` 가 배열이 아니거나 배열 안 원소가 object 가 아니면 무시. 잘못된 filter payload 로 500 이 나지 않게 정규화 + +#### Frontend filter resolver + +`OptionFilter.value_type` (canonical): +- `static` — `value` 그대로 전송 +- `field` — `field_ref` 가 `formData` 에 있어야 함. 없으면 해당 항목 fetch skip (카드는 idle 상태) +- `user` — `user_field` 가 `userContext` 에 있어야 함. 없으면 skip + +이렇게 해서 백엔드는 `column / operator / value` 만 받는 단순 모델 유지. `value_type` resolution 은 프론트에서 완료. + +#### 디자인 모드 / 캐싱 / race guard + +- `isDesignMode === true` → API 호출 0. 정적 `item.value` 표시 +- request key 캐시 (`buildRequestKey`): `tableName + aggregation + columnName + resolved filters` 의 stringify. 같으면 재호출 안 함 +- version 카운터로 stale response 무시 (effect 재실행 시 이전 버전 결과 drop) +- 카드 단위 try/catch — 한 카드 실패 시 다른 카드 영향 0. 실패 카드는 "—" 표시 + `state[key].error` +- 정적 stats / 디자인 모드 / fetch 대상 0건인 경우 `setState({})` 반복으로 렌더 루프가 생기지 않도록 plan signature 와 state 변경 여부를 체크 +- `InvStatsConfigPanel` 의 테이블 목록은 상위 패널에서 1회 `useDbTables()` 호출 후 각 item editor 로 전달. 항목 수만큼 테이블 목록 API 를 중복 호출하지 않음 + +#### Remaining limitations + +1. **컬럼 선택 UI** — 현재 컬럼명은 free text 입력. 테이블에 따라 컬럼 자동 완성 / dropdown 제공은 후속 phase 에서 (`tableManagementApi.getColumnList` 재사용 예정) +2. **숫자 포맷팅** — `StatsItem.format` 필드는 type 에만 있고 hook 결과 표시는 `Number.toLocaleString` 기본만. `#,##0.00` 등 커스텀 포맷 적용은 별도 작업 +3. **자동 갱신** — 현재는 mount 시 1회 + `refreshKey` 변경 시 재조회. polling / 주기적 refresh 옵션은 미구현 +4. **다중 테이블 배치 요청** — 같은 tableName 끼리 묶어서 1회 요청으로 처리하는 최적화는 미구현 (현재는 항목별 1요청). 카드 수가 적은 KPI 화면에서는 문제 없음 +5. **`value_type: "user"` 컨텍스트** — `StatsComponent` props 의 `companyCode / userId / deptCode / userName` 만 인식. 외부 wrapper 가 명시적으로 props 로 전달해야 함. 빌더 / 런타임 wiring 은 기존 패턴 그대로 +6. **DataPort 활성화 미함** — Phase G.0 결정대로 props / hook 으로만 동작. `DataPortBus` wiring 은 별도 phase + +#### Verification + +```bash +git diff --check PASS +git diff --cached --check PASS +cd backend-spring && ./gradlew compileJava BUILD SUCCESSFUL in 10s +rg "v2-input|v2-select|V2InputRenderer|V2SelectRenderer" 0 matches +rg "EntityPicker|entity-picker|EntitySearchModal" 0 matches (input + InvFieldConfigPanel) +rg 'componentType: "v2-file-upload"|file: ...' 0 matches +``` + +전체 `tsc` 는 기존 전역 camelCase/snake_case 에러로 실패하는 게 정상. G.1 변경 파일 한정 신규 에러 0 건 확인 (frontend 측 별도 ts 모듈 추가 + 기존 모듈 수정 모두 자체 컴파일 가능 — 의존하는 export 들은 그대로 유지). + +#### 다음 권장 단계 + +- **Phase G.2 Tabbed Data View** 진입. canonical `container` (`containerType: "tabs"`) 의 placeholder body 를 자식 컴포넌트 렌더로 교체. 새 ComponentType 추가 없이 `ContainerTab` 에 `components / dataSources / filters` 확장 +- G.1 마무리 후속: + - 컬럼 dropdown (테이블 변경 시 `getColumnList` 로 옵션 채움) + - `StatsItem.format` → 표시 포맷 적용 + - 같은 테이블 묶음 요청 최적화 (필요 시) + +--- + +### Phase G.2 Progress + +작업일: 2026-05-14. 브랜치 `gbpark-node`. + +#### Changed files + +Frontend (Next / TS): +- `frontend/lib/registry/components/container/types.ts` — `ContainerChildComponent` 신규, `ContainerTab` 에 `icon? / components? / dataSources? / filters? / viewType?` 추가 +- `frontend/lib/registry/components/container/ContainerComponent.tsx` — `containerType === "tabs"` 의 placeholder body 제거. 활성 탭의 `components` 를 `DynamicComponentRenderer` 로 렌더. `ChildSlot` 헬퍼 + drop-zone fallback 추가. props 전달 (`formData / userId / userName / companyCode / refreshKey` 등) +- `frontend/lib/registry/components/container/InvContainerConfigPanel.tsx` — 탭 row 에 `(N개)` 자식 개수 뱃지 추가 (minimal). 라벨 편집 / 추가 / 삭제 / 기본 탭 선택은 기존 그대로 + +Backend (Java/Spring): 변경 없음. +Documentation: 본 섹션 추가. + +#### Runtime behavior added + +- **활성 탭만 mount**: `tabs.find(t => t.id === activeTab)` 의 `components` 만 렌더. 비활성 탭은 React tree 에 진입조차 안 함 (성능 + side-effect 격리). 탭 전환 시 이전 탭 unmount. +- **빈 탭 fallback**: + - 디자인 모드: dashed border + "이 탭이 비어 있습니다. 좌측 팔레트에서 컴포넌트를 끌어다 놓으세요." 안내 (drop-zone-like). 실제 drop wiring 은 외부 빌더 책임이라 안내 텍스트만 + - 운영 모드: `null` 반환 (빈 영역) +- **자식 정규화**: `ContainerChildComponent.{ id, componentType, componentConfig, size }` 를 `DynamicComponentRenderer` 가 받는 `component { id, componentType, type, componentConfig, component_type, component_config, size }` 형태로 매핑. camelCase / snake_case 둘 다 채워서 renderer 측 fallback 매칭 100% 호환 +- **props 전파**: `formData / onFormDataChange / userId / userName / companyCode / screenId / menuId / menuObjid / tableName / selectedRows / selectedRowsData / onSelectedRowsChange / refreshKey / onRefresh / isInModal` 를 자식에 그대로 forward. DataPortBus wiring 은 활성화하지 않음 (G.0 결정) +- **자유 배치**: `size.width / height` 가 있으면 자식 wrap div 에 inline style 로 적용. 좌표 (position) 는 G.2 범위 밖 — 외부 빌더 / 차후 phase 책임 +- **탭 헤더**: 기존 UI (가로 button 라인, 활성 탭 primary color 보더) 그대로. `tab.icon` 이 있으면 라벨 앞에 표시 (이모지 / 짧은 문자열). 클릭 시 디자인 모드에서도 미리보기 전환 가능 (이전엔 디자인 모드에서 setActiveTab 막혀 있었음) + +#### Codex verification correction + +검증 중 확인된 작은 런타임 보정: +- `activeTab` 이 삭제되었거나 현재 `tabs` 목록에 없을 때 본문은 첫 탭으로 fallback 되는데, 헤더 active 표시는 stale `activeTab` 을 보던 문제를 `activeTabId = currentTab?.id` 기준으로 맞춤 +- 탭 내부 자식에게 `parentTabId / parentTabsComponentId` 를 전달해서 차후 빌더 선택 / 위치 업데이트 wiring 이 부모 탭을 식별할 수 있게 함 +- `ChildSlot` 주석에서 실제 구현되지 않은 try/catch boundary 언급 제거 + +#### Config panel changes + +- 탭 list 의 각 row 에 `(N개)` / `·` 미니 뱃지 추가. mono font, muted color. tooltip = "이 탭에 배치된 컴포넌트 수" +- 추가 / 삭제 / 라벨 편집 / 기본 탭 선택은 기존 그대로 유지 — 패널 재설계 안 함 +- 자식 컴포넌트의 실제 배치 / 편집은 캔버스 빌더 책임. config 패널은 메타 표시만 + +#### Type model (요약) + +```ts +export interface ContainerChildComponent { + id: string; + componentType: string; // "table" | "stats" | "search" | "button" | "input" | ... + componentConfig?: Record; + label?: string; + size?: { width?: number | string; height?: number | string }; +} + +export interface ContainerTab { + id: string; + label: string; + icon?: string; + components?: ContainerChildComponent[]; + dataSources?: unknown[]; // forward compat (runtime 미사용) + filters?: unknown[]; // forward compat (runtime 미사용) + viewType?: string; // forward compat (runtime 미사용) +} +``` + +`TemplateComponent` (`frontend/types/invyone-component.ts:822`) 보다 의도적으로 작음. order / row / responsive / parentId / groupChildren / inputs / outputs 등은 G.2 범위 밖 — 빌더 자유 배치 또는 DataPort 활성화 phase 영역. 차후 phase 에서 `ContainerChildComponent` 가 `TemplateComponent` 의 일부 필드를 흡수하는 식으로 확장 가능. + +#### Verification + +```bash +git diff --check PASS +git diff --cached --check PASS +rg "v2-input|v2-select|V2InputRenderer|V2SelectRenderer" frontend/... 0 matches +rg "EntityPicker|entity-picker|EntitySearchModal" input + InvFieldConfigPanel 0 matches +rg "v2-tabs-widget|componentType: \"tabs\"|id: \"tabs\"" frontend/... pre-existing only +tsc --noEmit --pretty false (filter container/ paths) 0 new errors +``` + +전체 `tsc` 총 에러 수 3376 — Phase G.1 종료 시점과 동일 (G.2 가 신규 에러 도입 0). 기존 전역 camelCase / snake_case · BlockRole 에러만 남음. + +`v2-tabs-widget` / `componentType: "tabs"` grep 결과 매치는 모두 G.2 작업 전부터 있던 alias / 마이그레이션 / 흡수 주석 / 레거시 폴더 코드 — G.2 신규 도입 0건. 주요 위치: +- `frontend/lib/registry/components/container/types.ts` — 흡수 대상 주석 (`v2-tabs-widget (탭)`). 기존 +- `frontend/lib/utils/getComponentConfigPanel.tsx` — alias `"v2-tabs-widget" → "container"`. 기존 +- `frontend/lib/utils/templateMigrate.ts` — `'v2-tabs-widget': 'container'` 마이그레이션 매핑. 기존 +- `frontend/lib/registry/DynamicComponentRenderer.tsx` — `LEGACY_TO_UNIFIED` 안의 alias. 기존 +- `frontend/lib/registry/components/v2-tabs-widget/` 폴더 자체. 기존 (Phase G.5 Legacy Cleanup 대상) +- `frontend/lib/registry/components/tabs/tabs-component.tsx` — `hidden: true` 레거시 폴더. 기존 (Phase G.5 대상) +- `frontend/components/screen/ScreenDesigner.tsx`, `RealtimePreviewDynamic.tsx`, `InteractiveScreenViewer.tsx`, `SplitPanelLayoutComponent.tsx`, `ComponentsPanel.tsx`, `componentConfig.ts` 등 — 모두 알려진 alias / hidden list / migration 코드. 기존 + +#### Remaining limitations + +1. **빌더 측 drag-drop 미연결** — `ChildSlot` 까지의 렌더 wiring 은 완료. 빌더가 캔버스에서 컴포넌트를 끌어다 놓아서 `tab.components` 에 추가하는 UI / store 업데이트는 외부 (ScreenDesigner 등) 책임. 현재는 데이터를 코드로 주거나 다른 화면에서 마이그된 경우만 표시 +2. **section / accordion / repeater / conditional 모드** — G.2 는 `containerType === "tabs"` 만 활성. 나머지 4 모드는 여전히 스켈레톤 placeholder. 각 모드의 자식 렌더는 별도 phase +3. **탭별 dataSources / filters runtime** — `ContainerTab.dataSources / filters` 는 type 에 추가만 됨. runtime 에서 이를 fetch 해서 자식에 prop 으로 주입하는 wiring 은 G.2 범위 밖 (G.3 View Components 와 함께 진행 예정) +4. **자식의 자유 좌표 배치** — `size.width / height` 만 적용. `position.x / y` 같은 absolute 좌표는 미지원 — vertical stack 만 함. 빌더에서 자유 배치 요구가 강해지면 차후 `ContainerChildComponent.position` 추가 +5. **`Template.connections` (DataPort) 활성화** — Phase G.0 결정대로 미활성. 자식들은 props 로만 데이터 전달받음. DataPortBus subscribe / publish 는 별도 phase +6. **탭별 lazy state 보존** — 현재는 탭 전환 시 이전 탭 자식이 완전 unmount. 자식 내부 상태 (테이블 페이지 / 검색어 등) 가 초기화됨. "hidden 으로만 숨김" 모드는 G.2 범위 밖. 필요 시 `keepMounted: boolean` 옵션 추가 가능 + +#### 다음 권장 단계 + +- **Phase G.3 View Components** — 탭 안에서 사용할 범용 뷰 컴포넌트 정리 (`dataTable / searchFilter / cardList / barChart / donutChart / groupedTable`). 차트는 `recharts ^3.2.1` 위에 canonical 컴포넌트 신설. `frontend/components/admin/dashboard/charts/` 를 reference 로 함 (직접 import X) +- 빌더 측 작업: 캔버스에서 컴포넌트를 컨테이너 탭에 drop 했을 때 `tab.components` 배열을 업데이트하는 wiring (`ScreenDesigner` 영역) +- 마이그레이션: 기존 `v2-tabs-widget` 의 `TabItem.components: TabInlineComponent[]` (snake_case `component_type / component_config`) 를 canonical `ContainerTab.components: ContainerChildComponent[]` (camelCase) 로 변환하는 adapter. `templateMigrate.ts` 에 한 줄 추가 + +--- + +### Phase G.2.5 Progress + +작업일: 2026-05-14. 브랜치 `gbpark-node`. + +#### 목적 + +G.2 가 끝낸 "활성 탭의 `components` 를 `DynamicComponentRenderer` 로 렌더" 위에, 빌더 사용자가 실제로 캔버스에서 **canonical container 의 탭 body 에 컴포넌트를 끌어다 놓고 / 선택하고 / 삭제** 할 수 있도록 wiring 한다. 새 component type / 새 store / 새 panel 추가 0건. 기존 v2-tabs-widget 빌더 인프라 (handleComponentDrop tab 분기, handleSelectTabComponent, onUpdateComponent 등) 를 그대로 재사용. + +#### Changed files + +Frontend (Next / TS): +- `frontend/lib/registry/components/container/ContainerComponent.tsx` + - 탭 body 컨테이너에 `data-tabs-container="true"`, `data-container-kind="canonical"`, `data-component-id`, `data-active-tab-id` 추가 + - 탭 body 빈 영역 클릭 시 `onSelectTabComponent(activeTabId, "", null)` 호출 → 선택 해제 + - `renderTabChildren` 가 `p.onSelectTabComponent`, `p.selectedTabComponentId`, `p.onUpdateComponent` 를 받아 자식 선택 / 삭제에 사용 + - `ChildSlot` 확장: design 모드 hover dashed outline, selected 시 primary outline, ✕ 삭제 버튼 (hover + selected 시 노출). 자식 자체의 클릭은 `pointer-events: none` 으로 차단해서 ChildSlot 의 선택 click 만 받게 함 +- `frontend/components/screen/ScreenDesigner.tsx` + - `handleComponentDrop` 의 tabs 분기 (line ~3858) 가 `compType === "container" && containerType === "tabs"` 도 인식하도록 확장 (`isCanonicalTabs` 변수) + - 그 외에는 한 줄도 안 건드림. 기존 흐름 (`updatedTabs / updatedTabsComponent` 생성, 중첩 split-panel 처리, `setLayout + saveToHistory`) 그대로 재사용 +- Backend: 변경 없음 +- Documentation: 본 섹션 추가 + +#### Tab drop 동작 흐름 + +1. 사용자가 좌측 palette 에서 컴포넌트 (예: `table`, `stats`, `search`, `button`, `input`) 를 드래그 +2. canonical `container` 의 `containerType === "tabs"` 의 body (= `[data-tabs-container="true"]`) 위에 drop +3. `handleComponentDrop` 이 tabsContainer 를 closest 로 찾고 `data-component-id` 로 container 인스턴스, `data-active-tab-id` 로 활성 탭 id 를 얻음 +4. layout.components 에서 container 인스턴스를 찾아 `componentType === "container"` + `componentConfig.containerType === "tabs"` 검증 +5. 새 child 객체 생성 후 `componentConfig.tabs[activeTabIdx].components` 끝에 push +6. `setLayout` + `saveToHistory` +7. ContainerComponent 가 next render 에서 새 child 까지 `DynamicComponentRenderer` 로 렌더 → 화면에 즉시 등장 + +#### 새 자식 shape (드롭 시 생성) + +```ts +{ + id: `tab_comp_${Date.now()}_${rand}`, + componentType: , // 예: "table", "stats", "search", "input" + label: , + position: { x, y }, // ContainerChildComponent 는 사용 안 함. 향후 자유 배치 phase 용으로 보존 + size: { width, height }, // ChildSlot 의 wrap 박스 폭/높이로 적용 + componentConfig: +} +``` + +`ContainerChildComponent` (Phase G.2 정의) 는 `id / componentType / componentConfig / label / size` 만 필수로 사용. drop 결과의 `position` 은 forward-compat 으로 남겨두지만 G.2.5 에서는 무시. 캔버스 자유 배치 단계는 별도 phase. + +#### 자식 선택 / 삭제 동작 + +- **선택**: 디자인 모드에서 자식 ChildSlot 클릭 → ChildSlot 의 onClick 이 `e.stopPropagation()` + `onSelectTabComponent(tabId, child.id, child)` 호출. ScreenDesigner 의 `handleSelectTabComponent` 가 `selectedTabComponentInfo` state 를 세팅하고 일반 `selectedComponent` 는 null 로 리셋 (기존 v2-tabs-widget 과 같은 동작) +- **선택 식별**: `selectedTabComponentId` prop 으로 어떤 자식이 선택됐는지 ChildSlot 에 전달됨. 선택된 자식은 2px primary outline +- **삭제**: 선택 / hover 시 우측 상단에 ✕ 버튼 표시. 클릭하면 `onUpdateComponent({...container, componentConfig: { ...컨피그, tabs: [...새tabs] }})` 호출. ScreenDesigner 의 `onUpdateComponent` (line 8327) 가 layout.components 를 갱신 + history 저장 +- **탭 body 빈 영역 클릭**: `onSelectTabComponent(tabId, "", null)` 으로 선택 해제 (기존 동작과 호환) + +#### Codex verification correction + +검증 중 확인된 작은 런타임 보정: +- 탭 body 직접 배경 클릭뿐 아니라 dashed 빈 탭 안내 영역을 클릭해도 자식 선택이 해제되도록 body click 조건을 완화 +- ChildSlot 은 선택 click 에서 `stopPropagation()` 하므로 자식 선택 / 삭제와 선택 해제 동작은 충돌하지 않음 + +#### Pointer events 처리 + +디자인 모드에서 자식 (예: 테이블의 행 / 셀, 버튼) 의 자체 클릭 핸들러가 ChildSlot 의 선택 click 을 가로채지 않도록, 자식 컴포넌트를 감싸는 inner div 에 `pointer-events: none` 적용. 운영 모드에서는 `pointer-events: auto` 기본값. 결과: + +- 디자인 모드: 자식 어디를 클릭하든 ChildSlot 선택만 일어남 +- 운영 모드: 자식 자체가 정상 동작 (테이블 클릭 / 버튼 클릭 등) + +#### Verification + +| 항목 | 결과 | +|---|---| +| `git diff --check` | PASS | +| `git diff --cached --check` | PASS | +| `rg "v2-input\|v2-select\|V2InputRenderer\|V2SelectRenderer"` | 0 matches | +| `rg "EntityPicker\|entity-picker\|EntitySearchModal"` (input + InvFieldConfigPanel) | 0 matches | +| `rg "componentType: \"v2-file-upload\"\|..."` | 0 matches | +| `tsc --noEmit` G.2.5 변경 파일 (`ContainerComponent.tsx`, `ScreenDesigner.tsx` 의 ~line 3858 영역) | 0 new errors | +| `tsc --noEmit` 전체 | 3376 (G.1/G.2 종료 시점과 동일 — G.2.5 신규 0) | + +ScreenDesigner.tsx 의 기존 camelCase / snake_case · LayoutData / ComponentData 미스매치 에러들은 모두 G.2.5 작업 전부터 있던 것. 본 phase 가 introduce 한 것 없음. + +#### Remaining limitations + +1. **자식 속성 편집 UI** — 좌측 패널 (V2PropertiesPanel) 은 현재 `selectedComponent` 만 본다. 자식 선택 시 `selectedTabComponentInfo` 가 세팅되지만 우측 props panel 은 "캔버스에서 블록을 선택하세요" 로 비어 보임. **임시 운영 방법**: + - 자식 컴포넌트 자체의 props 를 바꾸려면 일단 자식 컨테이너 탭에서 ✕ 로 삭제 후 새로 drop 하거나 + - 컨테이너를 클릭해 컨테이너 자체의 InvContainerConfigPanel 에서 탭 라벨 / 자식 개수 확인 + - 향후 phase: V2PropertiesPanel 우측 분기에 `selectedTabComponentInfo` 케이스 추가 (기존 `{false && ...}` 로 비활성화된 좌측 panel 블록 (line ~7221) 의 패턴 그대로 우측에 이전). 별도 단일 phase 로 처리 권장 +2. **자식 reorder UI** — 자유 순서 변경 미지원. `tab.components` 의 push 순서 그대로 위→아래 vertical stack. 향후 드래그 reorder 또는 ▲▼ 버튼 추가 가능 +3. **자유 좌표 (position) 배치** — drop 좌표는 `position` 에 저장만 되고 ChildSlot 은 vertical stack 만. position 기반 자유 배치는 별도 phase +4. **column / 테이블 컬럼 드래그 → 탭** — `handleDrop` 의 column 분기 (line ~4359) 는 아직 canonical container 의 탭 분기를 인식 안 함. 컬럼 drop 으로 자동 input 컴포넌트 생성은 별도 작업 (v2-tabs-widget 만 현재 지원) +5. **section / accordion / repeater / conditional 모드** — drop wiring 은 tabs 만. 나머지 4 모드는 G.2 / G.2.5 범위 밖 +6. **DataPort 활성화 안 함** — G.0 결정대로 props 기반만. 차후 phase +7. **`v2-tabs-widget` 마이그레이션 adapter** — 기존 스크린에 저장된 `v2-tabs-widget.TabItem.components: TabInlineComponent[]` 를 canonical `ContainerTab.components: ContainerChildComponent[]` 로 변환하는 adapter 는 미구현. `templateMigrate.ts` 의 `'v2-tabs-widget': 'container'` 매핑은 이미 있지만 components 배열 shape 차이 (`component_type` ↔ `componentType`) 는 그대로. canonical 컨테이너가 둘 다 읽을 수 있게 ChildSlot 의 컴포넌트 정규화가 snake_case + camelCase 둘 다 채워서 보내므로 표면적 동작은 OK. 명시 마이그 SQL 은 작성 금지 (G.0 원칙) + +#### Studio 에서 테스트하는 방법 + +1. ScreenDesigner 진입 후 좌측 팔레트에서 "container" 컴포넌트를 캔버스에 drop +2. 컨테이너 선택 → 우측 InvContainerConfigPanel ① 에서 "탭" 종류 선택 (CPVisualGrid 의 두 번째 카드) +3. ② 유형별 설정 의 "탭 추가" 버튼으로 탭 3개 만들기 + 각 탭 라벨 변경 (예: "사원 목록 / 부서별 현황 / 근태 현황") +4. 캔버스에서 탭 헤더 클릭 → 활성 탭 전환. 활성 탭 body 는 "탭이 비어 있습니다" dashed 영역 +5. 좌측 팔레트에서 `table` (또는 `stats` / `search` / `button` / `input`) 을 드래그 → 활성 탭 body 위에 drop +6. 자식이 즉시 vertical stack 위에 등장 + 우측 InvContainerConfigPanel ② 의 탭 row 에 "(1개)" 뱃지 증가 +7. 다른 탭으로 전환 → 그 탭은 빈 dashed 영역. 첫 탭의 자식 mount 는 해제됨 (탭별 독립 확인) +8. 5~7 반복 → 탭마다 다른 자식 배치 +9. 자식 hover → primary dashed outline + 우측 상단 ✕ 버튼. 자식 클릭 → primary solid outline (선택). ✕ 클릭 → 자식 삭제 +10. 운영 모드 / 미리보기 진입 시 자식이 실제 컴포넌트로 동작 (테이블이면 데이터 fetch 등). 디자인 모드 outline / ✕ 버튼은 자동 사라짐 + +--- + +### Phase G.2.6 Progress + +작업일: 2026-05-14. 브랜치 `gbpark-node`. + +#### 목적 + +G.2.5 가 끝낸 "canonical container 탭 안에 컴포넌트 drop / 선택 / 삭제" 위에, **자식 컴포넌트를 클릭하면 우측 properties 패널 (V2PropertiesPanel) 이 그 자식의 설정을 보여주고 편집까지 가능** 하도록 wiring. 이제 자식이 일반 최상위 컴포넌트와 동일한 편집 UX 를 갖는다. + +#### 파일 rename 사실 + +- 기존 빌더 컴포넌트 `frontend/components/screen/ScreenDesigner.tsx` 는 `InvyoneStudio.tsx` 로 rename 완료. 본 phase 가 작업한 파일은 **`InvyoneStudio.tsx`** 뿐. `ScreenDesigner_old.tsx` 는 legacy backup 이며 손대지 않음 +- `@/components/screen/ScreenDesigner` import 는 grep 0건이어야 함 + +#### Changed files + +Frontend (Next / TS): +- `frontend/components/screen/InvyoneStudio.tsx` + - 활성 우측 properties panel 분기 (line ~8782) 확장. 단일 `selectedComponent` 체크에서 **4-way branch** 로: + 1. `selectedTabComponentInfo` → 탭 자식 어댑터 + 핸들러로 `V2PropertiesPanel` 렌더 + 2. `selectedPanelComponentInfo` → 분할 패널 자식 어댑터 + 핸들러 + 3. `selectedComponent` → 기존 동작 + 4. 둘 다 없으면 안내 메시지 + - 새 inline 핸들러: `updateTabChildProperty / deleteTabChild / updatePanelChildProperty / deletePanelChild`. 모두 `setNestedValue` 로 깊은 path 안전 업데이트 + `saveToHistory(newLayout)` 호출 +- 기타 변경 없음. **disabled 좌측 패널 블록 (`{false && (...)}`) 은 그대로 비활성 유지**. 로직만 우측으로 이식 + +Backend: 변경 없음. +Documentation: 본 섹션 추가. + +#### 우측 properties 패널 선택 우선순위 + +``` +selectedTabComponentInfo (canonical container tabs / 옛 v2-tabs-widget 자식) + ↓ (있으면 stop) +selectedPanelComponentInfo (split-panel left / right 자식) + ↓ (있으면 stop) +selectedComponent (최상위 컴포넌트) + ↓ +"캔버스에서 블록을 선택하세요" 안내 +``` + +세 selection state 는 서로 배타적으로 관리됨: + +- `handleComponentSelect` (`InvyoneStudio.tsx:600`) — 최상위 컴포넌트 선택 시 `setSelectedTabComponentInfo(null)` + `setSelectedPanelComponentInfo(null)` +- `handleSelectTabComponent` (line 618) — 탭 자식 선택 시 `setSelectedComponent(null)` + `setSelectedPanelComponentInfo(null)` +- `handleSelectPanelComponent` (line 651) — 분할 패널 자식 선택 시 `setSelectedComponent(null)` + `setSelectedTabComponentInfo(null)` +- 탭 자식 ChildSlot 또는 탭 body 빈 영역 click → `onSelectTabComponent(tabId, "", null)` → `selectedTabComponentInfo = null`. 다른 state 가 없으면 4번 분기 (안내) + +#### 자식 어댑터 shape + +`selectedTabComponentInfo.component` (또는 `selectedPanelComponentInfo.component`) 를 `V2PropertiesPanel` 이 기대하는 `ComponentData` 로 변환: + +```ts +{ + id: child.id, + type: "component", + componentType: child.componentType, // "input" | "table" | "stats" | "search" | "button" | "container" | ... + label: child.label, + position: child.position ?? { x: 0, y: 0 }, + size: child.size ?? { width: 200, height: 100 }, + componentConfig: child.componentConfig ?? {}, + style: child.style ?? {}, +} +``` + +canonical 컴포넌트 id (예: `input / table / stats / search / button / container`) 는 그대로 보존. `getComponentConfigPanel` 의 canonical id 매핑이 정상 동작하므로 추가 코드 없이 동작. + +#### Nested update path 처리 + +`onUpdateProperty(componentId, path, value)` 가 깊은 path 를 받는다. `setNestedValue` 가 path 를 `.` split 해서 deep clone 위에 재할당. 예: + +- `label` → 단순 top-level key +- `size.width` → `{ ...comp, size: { ...comp.size, width: value } }` +- `componentConfig.title` → `{ ...comp, componentConfig: { ...comp.componentConfig, title: value } }` +- `componentConfig.dataSource.tableName` → 깊이 3 path. 중간 객체 없으면 자동 생성 +- `style` (단일 키, 객체 통째 교체) → `path === "style"` 분기에서 `{ ...comp, style: value }` +- `style.labelDisplay` → 깊이 2 path. 동일하게 처리 + +탭 자식 / 분할 패널 자식 양쪽 모두 동일 `setNestedValue`. 자식 편집 UX 가 최상위 편집과 사실상 동일. + +#### Selection 동기화 + +자식 컨피그 변경 후 `selectedTabComponentInfo.component` 도 동시에 갱신: + +```ts +if (updatedTabs) { + const updatedComp = updatedTabs + .find(t => t.id === tabId)?.components + ?.find(c => c.id === componentId); + if (updatedComp) { + setSelectedTabComponentInfo(prev => prev ? { ...prev, component: updatedComp } : null); + } +} +``` + +→ V2PropertiesPanel 이 다음 render 에서 갱신된 자식 데이터를 받음. onChange 가 panel 재렌더로 이어져도 "쓴 즉시 사라지는" 현상 없음. + +#### 삭제 핸들러 + +자식 properties panel 의 휴지통 / 삭제 액션 → `deleteTabChild(componentId)` 또는 `deletePanelChild(componentId)`: + +1. 해당 `tab.components` (또는 `panel.components`) 에서 `c.id !== componentId` 만 남기는 `filter` +2. 갱신된 컨테이너로 layout.components 업데이트 +3. `setSelectedTabComponentInfo(null)` (또는 panel 쪽) — selection 해제 +4. `saveToHistory(newLayout)` + +→ 자식이 즉시 사라지고 우측 패널이 안내 메시지로 복귀. + +ChildSlot 의 ✕ 버튼 (G.2.5) 은 별개 경로 — 컨테이너의 `onUpdateComponent` 를 직접 호출. 둘 다 같은 결과 (자식 제거 + 선택 해제). 동시 활성화돼도 race 없음 (둘 다 immutable update). + +#### 중첩 구조 (split-panel 안의 canonical container 탭) + +`selectedTabComponentInfo.parentSplitPanelId` + `parentPanelSide` 가 set 되어 있으면 자동으로 중첩 update 분기. canonical container 가 분할 패널의 leftPanel / rightPanel 안에 있어도 자식 properties 편집이 정상 작동. 옛 disabled 좌측 블록의 분기 패턴 그대로 이식. + +#### Verification + +| 항목 | 결과 | +|---|---| +| `git diff --check` | PASS | +| `git diff --cached --check` | PASS | +| `rg "v2-input\|v2-select\|V2InputRenderer\|V2SelectRenderer"` | 0 matches | +| `rg "EntityPicker\|entity-picker\|EntitySearchModal"` (input + InvFieldConfigPanel) | 0 matches | +| `rg 'componentType: "v2-file-upload"\|...'` | 0 matches | +| `rg "@/components/screen/ScreenDesigner\| +``` + +검증: +- `sanitize` + `hasColumn` 으로 식별자 안전성 (테이블 / groupBy / valueColumn 모두) +- `count` 외 집계는 `valueColumn` 필수 — 없으면 400 +- `sum / avg` 만 `CAST(col AS NUMERIC)` 자동 변환 (text 컬럼도 안전) +- `filters` 는 `/aggregate` 와 동일한 11종 operator (`=, !=, >, <, >=, <=, like, in, notIn, isNull, isNotNull`). 같은 `buildAggregateWhere` 헬퍼 공유 +- `limit` 은 1~500 (기본 50, chart 기본 12) +- 결과 정렬: `ORDER BY agg_value NULLS LAST`. 큰 그룹부터 (기본 desc) 또는 작은 그룹부터 + +#### Frontend runtime behavior + +ChartComponent 가 `useChartData(dataSource, { isDesignMode, formData, userContext, refreshKey, includeEmptyGroup })` 를 호출: + +- **디자인 모드**: API 호출 0. 정적 preview 4 row (`{ A:28, B:19, C:12, D:6 }`) 만 그려서 차트 모양 미리보기 +- **dataSource 미완성** (테이블 / groupBy 없음 또는 valueColumn 누락 시): 같은 preview 로 fallback. `isPreview === true` flag +- **운영 모드 + 완성된 dataSource**: `/aggregate-group` 호출. request key 캐시 (같은 호출 중복 0). version counter race guard +- **실패**: 카드 자체가 죽지 않고 "데이터 로드 실패: " 표시 +- **빈 그룹** (`group === null || group === ""`): 기본은 필터 아웃. `includeEmptyGroup: true` 면 "—" 라벨로 표시 +- `OptionFilter.value_type` 의 `field` / `user` 는 hook 안에서 `formData` / `userContext` 로 미리 치환 (stats 와 동일 패턴). 컨텍스트 누락 시 preview 모드로 유지 + +#### Config panel UX + +4-section 흐름: + +1. **① 기본** — 제목 + 차트 종류 (CPVisualGrid 4종: bar / horizontalBar / line / donut, 아이콘 포함) +2. **② 데이터 소스** — 테이블 (CPSelect, `useDbTables`) / 그룹 컬럼 / 집계 / 값 컬럼 (count 외만 노출) +3. **③ 필터** — `OptionFilter` 빌더 (stats 의 FilterRow 와 동일 패턴, column / operator / value_type / value). `isNull / isNotNull` 일 때 값 입력 숨김 +4. **④ 표시 옵션** — 최대 그룹 (CPNumber 1~500) / 정렬 (CPSegment) / 빈 그룹 포함 (CPSwitch) / 축 표시 (CPSwitch) / donut 면 범례 표시, bar·line 이면 막대·선 색 + +#### Verification + +| 항목 | 결과 | +|---|---| +| `git diff --check` | PASS | +| `git diff --cached --check` | PASS | +| `cd backend-spring && ./gradlew compileJava` | BUILD SUCCESSFUL in 3s | +| `rg "v2-input\|v2-select\|V2InputRenderer\|V2SelectRenderer"` | 0 matches | +| `rg "EntityPicker\|entity-picker\|EntitySearchModal"` (input + InvFieldConfigPanel) | 0 matches | +| `rg 'componentType: "v2-file-upload"\|...'` | 0 matches | +| `rg "@/components/screen/ScreenDesigner\| +``` + +검증: +- `sanitize` + `hasColumn` 식별자 안전성 (table / columns / filter columns / orderBy columns 모두) +- 없는 컬럼은 자동 drop (`columns` 중 일부만 유효하면 유효한 것만 SELECT, orderBy 도 마찬가지) +- `filters` 는 `/aggregate` / `/aggregate-group` 와 동일한 11종 operator. `buildAggregateWhere` 공유 헬퍼 +- 값은 JdbcTemplate `?` 파라미터 바인딩만. 식별자에 raw input 안 들어감 +- `limit` 1~500 (기본 50), `offset` ≥ 0 +- 잘못된 tableName → `IllegalArgumentException` → 400 + +#### Frontend runtime — useTableRows + +shared hook `_shared/use-table-rows.ts` 를 card-list / grouped-table 둘이 공유: + +- **디자인 모드**: API 호출 0. `previewBuilder?` 로 컴포넌트별 맞춤 preview row 4~6장 생성 +- **dataSource 미완성** (`tableName` 없음): 같은 preview 로 fallback. `isPreview === true` +- **운영 모드**: `/select-rows` 호출. request key 캐시 (`tableName + columns + filters + orderBy + limit` stringify) — 같은 호출 중복 0 +- **race guard**: version counter 로 stale response 무시 +- **실패**: throw 안 하고 `error` state 반환. 각 컴포넌트가 fallback 메시지 표시 +- **filter resolver**: `OptionFilter.value_type === "field" | "user"` → hook 내부에서 `formData` / `userContext` (companyCode, userId, deptCode, userName) 로 치환. 컨텍스트 누락 시 preview 모드로 폴백 + +#### card-list (id `"card-list"`) + +- 레이아웃: `list` (한 줄 카드, 우측 metric 정렬) 또는 `grid` (auto-fit, 200px 최소폭) +- 필드 매핑: `titleField` (위 큰 글씨) + `subtitleFields[]` (라인별 작은 글씨) + `metricFields[]` (`{ column, label?, emphasis? }`) +- `emphasis: true` → metric 칩이 primary 색 강조 (`var(--v5-primary)`) +- 빈 결과 / 에러 메시지 customizable (`emptyText`) + +#### grouped-table (id `"grouped-table"`) + +- 컬럼 정의: `GroupedTableColumn[] = { column, label?, width?, align? }`. 비우면 row 첫 항목의 모든 키 자동 표시 (`groupBy` 자체 제외, `__` prefix 키 제외) +- 클라이언트 측 group-by: `Map` 으로 묶음 + `sortGroups: "none" | "asc" | "desc"` (한국어 collator) +- 그룹 헤더: primary 색 라벨 + `(N건)` 카운트 +- `cellToText` 헬퍼: `null/undefined/""` → `"—"`, `number` → `toLocaleString`, `boolean` → `✓/✗`, `Date` → `toLocaleString` + +#### Config panel UX + +`OptionFilterRow` 공유 컴포넌트로 stats / chart / cardList / groupedTable 4 패널이 같은 필터 빌더 사용. 기존 stats / chart 의 inline FilterRow 와 정확히 동일 UX (column / operator / value_type / value, `isNull/isNotNull` 일 때 값 입력 숨김). + +cardList 패널 (4-section): +1. ① 기본 — 제목 + 레이아웃 (CPSegment) + grid 컬럼 수 + 빈 결과 문구 +2. ② 데이터 소스 — 테이블 (CPSelect) / 정렬 컬럼 / 정렬 방향 / 최대 행 +3. ③ 필드 매핑 — 제목 컬럼 + 부제 컬럼 N개 + metric 칩 N개 (column / label / emphasis switch) +4. ④ 필터 — OptionFilterRow 빌더 + +groupedTable 패널 (5-section): +1. ① 기본 — 제목 / 빈 결과 +2. ② 데이터 소스 — 테이블 / 정렬 / 최대 행 +3. ③ 그룹 — groupBy 컬럼 / 그룹 정렬 (원본 / 오름 / 내림) +4. ④ 컬럼 — 표시 컬럼 목록 (column / label / width / align) +5. ⑤ 필터 + +#### Verification + +| 항목 | 결과 | +|---|---| +| `git diff --check` | PASS | +| `git diff --cached --check` | PASS | +| `cd backend-spring && ./gradlew compileJava` | BUILD SUCCESSFUL in 1s | +| `rg "v2-input\|v2-select\|V2InputRenderer\|V2SelectRenderer"` | 0 matches | +| `rg "EntityPicker\|entity-picker\|EntitySearchModal"` (input + InvFieldConfigPanel) | 0 matches | +| `rg 'componentType: "v2-file-upload"\|...'` | 0 matches | +| `rg "@/components/screen/ScreenDesigner\|..."` | `ScreenDesigner_old.tsx` 만 (legacy backup, 활성 import 0) | +| `tsc --noEmit` G.3.1 변경 파일 한정 | 0 errors | +| `tsc --noEmit` 전체 | 3376 (G.3 종료와 동일 — G.3.1 신규 0건) | + +#### Codex verification follow-up + +검증 중 발견한 운영 리스크 2건을 같은 Phase G.3.1 범위 안에서 보정: + +- `selectTableRows` 의 `filters` / `orderBy` 입력을 unchecked cast 대신 `normalizeAggregateFilters(...)` 로 정규화. 잘못된 shape 가 들어와도 `ClassCastException` 으로 500 이 나지 않고, 유효한 map 항목만 처리된다. +- `card-list` / `grouped-table` 의 `previewBuilder` 를 `useCallback` 으로 고정. 디자인 모드 또는 dataSource 미완성 상태에서 preview row 재생성 → effect 재실행 → state update 반복으로 이어질 수 있는 루프를 차단했다. + +#### Studio 에서 테스트하는 방법 + +1. ScreenDesigner (=InvyoneStudio) 진입 → 좌측 팔레트에서 `container` drop → containerType "탭" + 탭 3개 ("사원 목록 / 부서별 현황 / 근태 현황") +2. **"부서별 현황" 탭** 활성 → 좌측 팔레트 `chart` drop → 클릭하면 우측 InvChartConfigPanel. ② 에서 테이블 = `user_info`, 그룹 컬럼 = `dept_code`, 집계 = `건수` → 즉시 막대 차트 +3. 같은 탭에 추가로 `card-list` drop → 우측 InvCardListConfigPanel. ② 테이블 = `dept_info` (예시), 정렬 = `dept_name asc`, 최대 = 20. ③ 제목 컬럼 = `dept_name`, 부제 = `[dept_code]`, metric = `[{column: "head_count", label: "인원", emphasis: true}]` → 부서 카드 카탈로그 +4. **"근태 현황" 탭** 활성 → `grouped-table` drop → 우측 InvGroupedTableConfigPanel. ② 테이블 = `attendance`, 정렬 = `attendance_date desc`. ③ 그룹 = `attendance_date`, 그룹 정렬 = `desc`. ④ 컬럼 = `[{column: "user_name", label: "사원"}, {column: "check_in", label: "출근"}, {column: "check_out", label: "퇴근"}, {column: "work_hours", label: "근무시간", align: "right"}]` → 날짜별 그룹 row + 안쪽 사원 row +5. 탭 전환 → 각 탭의 컴포넌트가 독립적으로 mount/unmount, 데이터 서로 영향 없음 +6. 운영 모드 / 미리보기 진입 시 chart / card-list / grouped-table 모두 실 데이터로 동작 + +#### Remaining limitations + +1. **컬럼 자동완성 dropdown 미지원** — card-list / grouped-table 의 columns / groupBy / orderBy 컬럼은 모두 free text. `tableManagementApi.getColumnList` 활용은 후속 phase 일괄 처리 권장 (stats / chart 도 동일 한계) +2. **server-side 페이지네이션 / 무한 스크롤** — card-list / grouped-table 모두 `limit` 으로 첫 N행만 가져옴. 페이지네이션 UI 는 별도 (Phase G.4 이후) +3. **drill-down / 클릭 인터랙션** — 카드 / 그룹 row 클릭 시 DataPort publish 등은 G.0 결정대로 미활성 +4. **grouped-table 의 그룹 접기/펼치기** — 항상 펼침. collapse 토글은 별도 작업 +5. **그룹 헤더 집계 (sum / avg)** — 현재 그룹 헤더는 `(N건)` 카운트만. 그룹 별 column 합계 / 평균 표시는 후속 +6. **i18n** — `"데이터 없음"`, `"건"`, `"미리보기"` 등 한국어 fixed. 다국어는 별도 일관 작업 +7. **자동 갱신** — `refreshKey` 변경 시 재호출. polling 미지원 +8. **자식의 자유 위치** — canonical container 탭 안에서 자식 컴포넌트는 vertical stack 만. position.x/y 무시 (G.2.5 결정 유지) + +#### 다음 권장 단계 + +- **Phase G.4 인사 관리 화면 검증** — canonical stats / chart / card-list / grouped-table / table / search / button / input 만으로 인사 관리 예시 화면 (상단 통계 카드 4 + 사원 목록 탭 + 부서별 현황 탭 + 근태 현황 탭) 재현. 화면 이름과 예시 데이터는 인사 관리여도 구현은 범용 유지 +- **Phase G.5 Legacy Cleanup** — `frontend/lib/registry/components/ChartRenderer.tsx` placeholder, `frontend/components/screen/config-panels/ChartConfigPanel.tsx` 옛 placeholder, `v2-aggregation-widget` / `v2-status-count` / `v2-tabs-widget` 등 alias-only legacy 폴더 일괄 제거 검토 + +--- + +### Phase G.4.1 Progress + +작업일: 2026-05-14. 브랜치 `gbpark-node`. + +#### 목적 + +canonical `stats` 항목 편집 UX 가 정적 카드 가정으로 동작해, DB-bound KPI 카드 (`전체 사원 / 재직 중 / 휴직 중 / 겸직 현황`) 에 부적합. 사용자가 값을 직접 타이핑하고 변화량 (delta) 까지 필수처럼 강요되는 인상을 줌. G.4.1 은 stats 의 **편집 UX 를 DB-first** 로 정리. 정적 fallback 은 보존하되 보조 자리로 내림. + +#### Changed files + +Frontend (Next / TS): +- `frontend/lib/registry/components/stats/InvStatsConfigPanel.tsx` — 항목 row 재설계 + - 단일 sourceTable 데이터 소스 섹션 ②, "변화량" 메인 row 제거 + - collapsed row 에 **DB / 정적 모드 뱃지 (`ModeBadge`)** 추가. 클릭 1번으로 모드 전환 + - DB 모드: collapsed 에서 값 입력 숨김, expanded 에서 DB 바인딩 (테이블 / 집계 / 컬럼 / 필터) 우선 표시 + - 정적 모드: collapsed 에서 fallback 값 입력 보임, expanded 에서 "정적 값" 박스로 명확화 + - 외형 (아이콘 / 색) 은 양쪽 공통 박스 + - **비교 / 변화량** 은 expanded 안의 접기-펼치기 sub-section, 기본 닫힘. `item.delta` 비우면 카드에 비표시 + - local `FilterRow` 제거, 공유 `OptionFilterRow` (`_shared/FilterRow`) 사용 + - `addItem` 의 default `value: 0` 제거 → label 만 가진 빈 항목 (DB 모드 전환 후 자연스럽게 "—" 표시) + - 색상 프리셋이 항목 4개 자동 생성할 때도 `value: 0` 박지 않음 +- `frontend/lib/registry/components/stats/StatsComponent.tsx` — runtime fallback 의미 정리 + - default items 도 `value: 0` 제거 (label 만) + - `displayValue` 결정: DB-bound + fetch 미완 / 디자인 모드 → `item.value !== undefined ? formatValue(item.value) : "—"`. 강제 `value ?? 0` 제거 + - 정적 모드도 동일하게 명시 안 한 값은 "—". lucide icon 렌더링 (`renderIcon`) 보존 +- Documentation: 본 섹션 추가. + +Backend / API 변경 없음. + +#### UX 의 새 의미 (한국어 정리) + +| 모드 | 의미 | collapsed row | expanded 우선 표시 | +|---|---|---|---| +| **DB** | 값은 DB 집계로 자동 계산. 정적 입력 없음. | 번호 · 색점 · 라벨 · `[DB]` 뱃지 (primary) · 펼침 · 삭제 | DB 바인딩 (테이블 / 집계 / 컬럼 / 필터) → 외형 → 비교/변화량 (옵션) | +| **DB (테이블 미설정)** | DB 모드 토글했지만 테이블 미선택 — 디자인 모드 "—" 표시 | `[DB]` 뱃지가 amber + tooltip 안내 | DB 바인딩 헤더에 "테이블 미설정" 라벨 | +| **정적** | DB 미사용. 사용자 입력 값 또는 미입력 시 "—". | 번호 · 색점 · 라벨 · `[정적]` 뱃지 · fallback 값 input · 펼침 · 삭제 | "정적 값" 박스 → 외형 → 비교/변화량 (옵션) | + +- 변화량 (`item.delta`) 은 어디서도 "필수" 같지 않음. expanded 안에서 접혀 있고, 비우면 카드에 아무 줄도 안 그려짐 +- StatsComponent 렌더 조건은 그대로 `item.delta && ...` + +#### 인사 관리 KPI 카드 4개 설정 예시 (도메인 hardcode 0건) + +각 카드는 **DB 모드** + `user_info` / `user_dept_role` 테이블 + count 기반. 화면 / 데이터는 인사 관리 예시지만 구현은 범용: + +| 카드 라벨 | DB / 정적 | 테이블 | 집계 | 컬럼 | 필터 | +|---|---|---|---|---|---| +| 전체 사원 | DB | `user_info` | 건수 | (생략) | (없음) — 테이블 전체 row 카운트 | +| 재직 중 | DB | `user_info` | 건수 | (생략) | `status = "재직"` (value_type=static) | +| 휴직 중 | DB | `user_info` | 건수 | (생략) | `status = "휴직"` (value_type=static) | +| 겸직 현황 | DB | `user_dept_role` | 고유 | `user_id` | (없음) — distinctCount(user_id) | + +설정 흐름: +1. 컨테이너 / 탭 위에 `stats` drop → 우측 InvStatsConfigPanel 진입 +2. ④ 항목 list 의 `[+ 추가]` 로 항목 1개 생성 → 라벨 "전체 사원" 입력 +3. collapsed row 의 `[정적]` 뱃지 클릭 → `[DB]` 로 전환 (primary 색) +4. row 펼치기 → DB 바인딩 박스에서 테이블 = `user_info`, 집계 = `건수` +5. 항목 추가 후 "재직 중" 라벨 + DB 뱃지 + 펼침 → 같은 테이블 / 집계 + 필터 `+ 추가` → `column="status", operator="=", value_type=고정값, value="재직"` +6. "휴직 중" 동일, `value="휴직"` +7. "겸직 현황" → DB 모드 + `user_dept_role` + 집계 = `고유` + 컬럼 = `user_id`. 필터 없음 → distinctCount +8. 어떤 카드에도 정적 값 / 변화량 강제 노출 없음. 디자인 모드에서는 fetch 안 함 → "—" 표시. 운영 모드 진입 시 실 DB 값으로 자동 채워짐 + +다른 도메인 예 (영업 / 재고): +- `매출 합계` = sum / `orders.amount` / filter `status="completed"` +- `재고 부족` = count / `stock` / filter `quantity < 10` + +같은 패턴 — 도메인 hardcode 0건. + +#### Verification + +| 항목 | 결과 | +|---|---| +| `git diff --check` | PASS | +| `git diff --cached --check` | PASS | +| `rg "v2-input\|v2-select\|V2InputRenderer\|V2SelectRenderer"` | 0 matches | +| `rg "EntityPicker\|entity-picker\|EntitySearchModal"` | 0 matches | +| `rg 'componentType: "v2-file-upload"\|...'` | 0 matches | +| `tsc --noEmit` G.4.1 변경 파일 한정 (`stats/`, `_shared/`) | 0 new errors | +| `tsc --noEmit` 전체 | 3376 (G.3.1 종료와 동일 — G.4.1 신규 0건) | + +#### Codex verification follow-up + +검증 중 실제 생성 경로의 잔여 `0` 기본값을 추가 보정: + +- `frontend/lib/registry/components/stats/index.ts` 의 `StatsDefinition.default_config.items` 에 남아 있던 `value: 0` 제거. 새 `stats` 컴포넌트를 드롭할 때부터 강제 0 값이 들어가지 않는다. +- 기본 아이콘은 문자열로 보이는 이모지 대신 lucide 이름 (`BarChart3`, `TrendingUp`, `DollarSign`) 으로 정리. `StatsComponent.renderIcon` 경로에서 SVG 로 표시된다. + +#### Remaining limitations + +1. **컬럼 자동완성 dropdown 미지원** — DB 모드의 `컬럼` 입력은 여전히 free text. stats / chart / cardList / groupedTable 공통 — `tableManagementApi.getColumnList` dropdown 일괄 도입은 별도 phase +2. **`distinctCount` 컬럼 미설정 inline 안내** — 현재는 백엔드 400 으로만 알려줌. config panel 측 inline error highlight 는 후속 +3. **DB 모드 fallback 값 UI** — DB 모드의 정적 fallback (운영 모드 fetch 실패 시 보임) 입력은 현재 collapsed 에서 숨김. 필요해지면 expanded DB 바인딩 박스 안에 추가 노출 검토 +4. **delta 자동 계산** — `delta` 는 여전히 수동 입력. "DB 에서 전월 대비 자동 계산" 같은 시스템은 별도 phase +5. **i18n** — `"DB"`, `"정적"`, `"비교 / 변화량"` 한국어 fixed. 다국어는 전역 i18n phase 와 함께 + +--- + +### Phase G.4.2 Progress + +작업일: 2026-05-14. 브랜치 `gbpark-node`. + +#### 목적 + +G.4.1 의 DB/정적 두-모드 토글 UX 도 여전히 잘못된 멘탈 모델. stats 는 **KPI / 집계** 컴포넌트이므로 "정적 값 카드 vs DB 카드" 이분법이 부적절. G.4.2 는 stats 의 편집 UX 와 default 를 **완전히 DB-first** 로 재설계. fallback value / 변화량은 "고급" 섹션 안의 보조 옵션으로만 남김. + +#### Changed files + +Frontend (Next / TS): +- `frontend/lib/registry/components/stats/InvStatsConfigPanel.tsx` — 전면 재작성 + - `ModeBadge` (DB / 정적 토글) 완전 제거 + - collapsed KPI row: 번호 · 색점 · 라벨 · **테이블 inline select** · **집계 inline select** · **필터 개수 뱃지** · 펼침 · 삭제 + - 테이블 미선택 시 inline select 가 amber 보더 + tooltip "테이블 미설정" + - expanded: flat CP rows. `SectionLabel` + `CPRow` 만, 카드-속-카드 X + - **데이터** (테이블 / 집계 / 컬럼 when needed) + - **필터** (`OptionFilterRow` 리스트) + - **외형** (아이콘 / 색) + - **고급** (collapsible, 기본 닫힘): `Fallback 값` + `변화량` + `변화 방향` + - `addItem` default = `{ label: "항목 N", dataSource: { aggregation: "count" } }` + - 색상 프리셋 자동 생성도 동일 DB-first +- `frontend/lib/registry/components/stats/index.ts` — `DEFAULT_CONFIG.items` 도 DB-first placeholder 로 변경. `value` 박지 않음. 각 항목이 `dataSource: { aggregation: "count" }` 로 시작. 주석 wording 도 "DB 바인딩" 으로 정리 +- `frontend/lib/registry/components/stats/StatsComponent.tsx` — runtime default items 도 동일 DB-first. fallback 의미 유지 (G.4.1 에서 한 displayValue 정리 그대로) + +Backend / 다른 컴포넌트 변경 없음. +Documentation: 본 섹션 추가. + +#### DB-first stats 의미 (정리) + +**모드 개념 제거**. stats 는 항상 KPI 항목으로 동작: +- 모든 새 항목은 `dataSource: { aggregation: "count" }` 로 시작 +- 사용자가 collapsed row 에서 테이블을 inline 으로 골라 즉시 집계 활성화 +- 디자인 모드 / 테이블 미설정 / fetch 미완 시 → 카드에 `—` 표시 +- 운영 모드 fetch 성공 → 실 DB 집계 값 표시 +- 옛 row (`dataSource` 없이 `value` 만 가진 legacy data) 도 backward-compat 으로 그대로 값 표시. 새 row 는 그 길로 생성 안 됨 +- `value` 는 "Fallback 값" 으로 의미가 좁아짐 — 고급 섹션 안에서만 입력 가능. 디자인 모드 미리보기 / API 실패 안전망 용 +- `delta` 도 고급 안에서만 입력. 비우면 카드에 안 그려짐. 자동 계산은 미구현 — 별도 phase + +**collapsed KPI row 한 줄 메타**: + +``` +[#1] [●] [라벨...] [테이블 ▼] [건수 ▼] [⌽3] [▾] [🗑] + ↑ ↑ + 라벨 input 필터 3개 뱃지 (있을 때 primary) +``` + +#### 인사 관리 KPI 4개 설정 예시 (DB-first 그대로) + +새 stats 컴포넌트 drop 후, ④ KPI 항목의 첫 row 가 이미 DB placeholder. 라벨만 바꾸고 collapsed inline 으로 테이블 / 집계 선택: + +| 카드 | 테이블 (collapsed inline) | 집계 (collapsed inline) | 컬럼 | 필터 (expanded) | +|---|---|---|---|---| +| 전체 사원 | `user_info` | 건수 | — | 없음 | +| 재직 중 | `user_info` | 건수 | — | `status = "재직"` | +| 휴직 중 | `user_info` | 건수 | — | `status = "휴직"` | +| 겸직 현황 | `user_dept_role` | 고유 | `user_id` | 없음 | + +설정 흐름: +1. `stats` drop → ④ 의 첫 row 라벨 "전체 사원" 입력 → row 의 테이블 셀렉터에서 `user_info`. 집계는 이미 `건수`. 끝 +2. `[+ 추가]` 로 row 추가 → 라벨 "재직 중" → 테이블 `user_info` → 펼침 → 필터 `+ 추가` → `column="status", op="=", value_type=고정값, value="재직"` +3. "휴직 중" 동일, `value="휴직"` +4. "겸직 현황" → 라벨 → 테이블 `user_dept_role` → 집계 `고유` 선택 → row 펼침에 자동으로 "컬럼" CPRow 노출 → `user_id` 입력 +5. 모든 카드에 정적 값 / 변화량 강제 노출 0건. 디자인 모드 표시 = `—`, 운영 모드 = 실 DB 값 + +다른 도메인 (영업 / 재고 / 고객) 동일 패턴 — 도메인 hardcode 0건: +- `총 매출` = `orders` / 합계 / `amount` / filter `status="완료"` +- `평균 납기` = `purchase` / 평균 / `lead_time` +- `고객 수` = `customers` / 고유 / `customer_id` + +#### Verification + +| 항목 | 결과 | +|---|---| +| `git diff --check` | PASS | +| `git diff --cached --check` | PASS | +| `rg "value: 0\|value ?? 0\|정적 값\|[정적]\|ModeBadge"` (stats 폴더) | 0 matches | +| `rg "v2-input\|v2-select\|V2InputRenderer\|V2SelectRenderer"` | 0 matches | +| `rg "EntityPicker\|entity-picker\|EntitySearchModal"` | 0 matches | +| `rg 'componentType: "v2-file-upload"\|...'` | 0 matches | +| `tsc --noEmit` G.4.2 변경 파일 한정 (`stats/`, `_shared/`) | 0 new errors | +| `tsc --noEmit` 전체 | 3376 (G.4.1 종료와 동일 — G.4.2 신규 0건) | + +#### Remaining limitations + +1. **컬럼 자동완성 dropdown** — stats 의 `컬럼` 입력은 여전히 free text. chart / cardList / groupedTable 도 동일, `tableManagementApi.getColumnList` 일괄 도입은 별도 phase +2. **`distinctCount` 컬럼 미설정 inline 검증** — 현재 백엔드 400 으로만 알려줌. config panel inline error highlight 는 후속 +3. **delta 자동 계산** — `delta` 는 여전히 수동 입력 ("고급" 섹션 안) +4. **legacy 항목 backward compat** — `dataSource` 없이 `value` 만 가진 옛 row 는 그대로 값 표시되지만, 새 컴포넌트에서는 더 이상 생성 경로 없음. 옛 데이터 마이그레이션은 별도 작업 (필요 시) +5. **i18n** — `"테이블"`, `"건수"`, `"고급"` 등 한국어 fixed. 다국어는 별도