feat(studio): Phase G — KPI stats / chart / cardList / groupedTable + canonical container tabs
INV Studio 데이터 뷰 시리즈. 솔루션 개발 단계라 backward-compat alias 없이 깔끔하게. Backend: - TableManagementController + Service: /aggregate, /aggregate-group, /select-rows endpoint 추가 sanitize + hasColumn 검증 + buildAggregateWhere 공유 헬퍼 Frontend canonical view components (신규): - stats: DB-first KPI editor (CPSegment 메타 chip, 컬럼 dropdown, 디자인 모드 debounce 350ms preview) - chart: recharts (bar / horizontalBar / line / donut) - card-list: title/subtitles/metrics 카드 카탈로그 (list / grid 레이아웃) - grouped-table: 클라이언트 측 groupBy + 그룹 헤더 row Canonical container (Phase G.2 / G.2.5 / G.2.6): - containerType='tabs' 활성 탭만 mount, ChildSlot 으로 자식 렌더 - ScreenDesigner.handleComponentDrop 가 canonical container tabs 도 인식 - 우측 V2PropertiesPanel 4-way 분기: tab child / panel child / selected / empty nested path update + saveToHistory, delete handler 동기화 Shared utilities: - useDbColumns hook (모듈 캐시), ColumnPicker (CPSelect 기반) - OptionFilterRow 자연어 카드 형식 (컬럼 dropdown / 조건 select / 값 입력) - _shared/use-table-rows.ts (cardList + groupedTable 공용 fetch) - IconPicker: 한글 키워드 80+ alias, 휠 스크롤 fix, 360px 상한, 결과 80→300 stats DB-first UX (Phase G.4.x): - DB / 정적 모드 이분법 제거 — 항상 dataSource 시작 - collapsed: 라벨 input + KpiMetaSegment chip (테이블 · 집계 · 컬럼 · 필터수) - expanded: 데이터 / 필터 / 외형 / 고급 flat CP rows - useSlideToggle hook 으로 펼침/닫힘 양방향 애니메이션 - 변화량 (delta) 수동 입력 UI 제거 — 향후 DB 자동 계산 영역 - 카드 fetch state 명시: loading / error / 대기 중 / 테이블 미설정 기타: - ScreenDesigner.tsx → InvyoneStudio.tsx rename (활성 빌더 파일) - 모든 hardcoded #6c5ce7 fallback 제거, hsl(var(--primary)) 토큰만 사용 (light/dark/테마 자동 적응) - StatsDefinition default_config 도 DB-first placeholder (value: 0 박지 않음) Docs: - notes/gbpark/2026-05-14-studio-data-view-roadmap.md (G.0 ~ G.4.2 진행 기록) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -325,6 +325,57 @@ public class TableManagementController {
|
||||
"테이블 데이터를 성공적으로 조회했습니다."));
|
||||
}
|
||||
|
||||
/** POST /api/table-management/tables/:tableName/aggregate
|
||||
* body: { aggregation: "count"|"sum"|..., columnName?: string, filters?: [...] }
|
||||
* → { value: number }
|
||||
*/
|
||||
@PostMapping("/tables/{tableName}/aggregate")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> aggregateTableData(
|
||||
@PathVariable String tableName,
|
||||
@RequestBody Map<String, Object> options) {
|
||||
try {
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
tableManagementService.aggregateTableData(tableName, options == null ? Map.of() : options),
|
||||
"테이블 집계를 성공적으로 조회했습니다."));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /api/table-management/tables/:tableName/aggregate-group
|
||||
* body: { aggregation, groupBy, valueColumn?, filters?, limit?, orderDir? }
|
||||
* → { rows: [{ group, value }, ...] }
|
||||
*/
|
||||
@PostMapping("/tables/{tableName}/aggregate-group")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> aggregateTableGroup(
|
||||
@PathVariable String tableName,
|
||||
@RequestBody Map<String, Object> options) {
|
||||
try {
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
tableManagementService.aggregateTableGroup(tableName, options == null ? Map.of() : options),
|
||||
"테이블 그룹 집계를 성공적으로 조회했습니다."));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /api/table-management/tables/:tableName/select-rows
|
||||
* body: { columns?, filters?, orderBy?, limit?, offset? }
|
||||
* → { rows: [{...}, ...] }
|
||||
*/
|
||||
@PostMapping("/tables/{tableName}/select-rows")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> selectTableRows(
|
||||
@PathVariable String tableName,
|
||||
@RequestBody Map<String, Object> options) {
|
||||
try {
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
tableManagementService.selectTableRows(tableName, options == null ? Map.of() : options),
|
||||
"테이블 row 를 성공적으로 조회했습니다."));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /api/table-management/tables/:tableName/record (단일 레코드) */
|
||||
@PostMapping("/tables/{tableName}/record")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getTableRecord(
|
||||
|
||||
@@ -455,6 +455,369 @@ public class TableManagementService extends BaseService {
|
||||
return result;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// 동적 테이블 집계 (count / sum / avg / min / max / distinctCount)
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
private static final Set<String> AGG_TYPES = Set.of(
|
||||
"count", "sum", "avg", "min", "max", "distinctCount"
|
||||
);
|
||||
|
||||
private static final Set<String> FILTER_OPS = Set.of(
|
||||
"=", "!=", ">", "<", ">=", "<=",
|
||||
"like", "in", "notIn", "isNull", "isNotNull"
|
||||
);
|
||||
|
||||
/**
|
||||
* 단일 집계 값 계산.
|
||||
*
|
||||
* count — column 없이도 동작 (COUNT(*))
|
||||
* sum/avg/min/max — column 필수
|
||||
* distinctCount — column 필수 (COUNT(DISTINCT col))
|
||||
*/
|
||||
public Map<String, Object> aggregateTableData(String tableName, Map<String, Object> options) {
|
||||
String safeTable = sanitize(tableName);
|
||||
if (safeTable.isBlank() || !checkTableExists(safeTable)) {
|
||||
throw new IllegalArgumentException("테이블이 존재하지 않습니다: " + tableName);
|
||||
}
|
||||
|
||||
String aggregation = options.get("aggregation") instanceof String s ? s : "count";
|
||||
if (!AGG_TYPES.contains(aggregation)) {
|
||||
throw new IllegalArgumentException("지원하지 않는 집계 타입: " + aggregation);
|
||||
}
|
||||
|
||||
String columnName = options.get("columnName") instanceof String s ? s : null;
|
||||
String safeColumn = columnName != null ? sanitize(columnName) : "";
|
||||
|
||||
boolean columnRequired = !"count".equals(aggregation);
|
||||
if (columnRequired) {
|
||||
if (safeColumn.isBlank()) {
|
||||
throw new IllegalArgumentException(aggregation + " 은 columnName 이 필요합니다.");
|
||||
}
|
||||
if (!hasColumn(safeTable, safeColumn)) {
|
||||
throw new IllegalArgumentException("컬럼이 존재하지 않습니다: " + tableName + "." + columnName);
|
||||
}
|
||||
} else if (!safeColumn.isBlank() && !hasColumn(safeTable, safeColumn)) {
|
||||
// count + columnName 가 들어왔지만 실제 없는 컬럼이면 명확히 거절
|
||||
throw new IllegalArgumentException("컬럼이 존재하지 않습니다: " + tableName + "." + columnName);
|
||||
}
|
||||
|
||||
List<Map<String, Object>> filters = normalizeAggregateFilters(options.get("filters"));
|
||||
|
||||
List<Object> values = new ArrayList<>();
|
||||
String where = buildAggregateWhere(safeTable, filters, values);
|
||||
|
||||
String selectExpr;
|
||||
if ("count".equals(aggregation)) {
|
||||
selectExpr = !safeColumn.isBlank()
|
||||
? String.format("COUNT(\"%s\")", safeColumn)
|
||||
: "COUNT(*)";
|
||||
} else if ("distinctCount".equals(aggregation)) {
|
||||
selectExpr = String.format("COUNT(DISTINCT \"%s\")", safeColumn);
|
||||
} else {
|
||||
// sum / avg / min / max — 숫자 캐스팅 (avg 만 numeric, 나머지는 컬럼 타입 그대로)
|
||||
String upper = aggregation.toUpperCase();
|
||||
if ("AVG".equals(upper) || "SUM".equals(upper)) {
|
||||
selectExpr = String.format("%s(CAST(\"%s\" AS NUMERIC))", upper, safeColumn);
|
||||
} else {
|
||||
selectExpr = String.format("%s(\"%s\")", upper, safeColumn);
|
||||
}
|
||||
}
|
||||
|
||||
String sql = String.format("SELECT %s AS agg_value FROM \"%s\" main %s",
|
||||
selectExpr, safeTable, where);
|
||||
|
||||
Number raw = jdbcTemplate.queryForObject(sql, Number.class, values.toArray());
|
||||
double value = raw != null ? raw.doubleValue() : 0d;
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("value", value);
|
||||
return result;
|
||||
}
|
||||
|
||||
private String buildAggregateWhere(String safeTable, List<Map<String, Object>> filters, List<Object> values) {
|
||||
if (filters == null || filters.isEmpty()) return "";
|
||||
List<String> clauses = new ArrayList<>();
|
||||
for (Map<String, Object> f : filters) {
|
||||
if (f == null) continue;
|
||||
String col = f.get("column") instanceof String s ? s : null;
|
||||
String op = f.get("operator") instanceof String s ? s : "=";
|
||||
if (col == null || col.isBlank()) continue;
|
||||
String safeCol = sanitize(col);
|
||||
if (safeCol.isBlank() || !hasColumn(safeTable, safeCol)) continue;
|
||||
if (!FILTER_OPS.contains(op)) continue;
|
||||
|
||||
Object val = f.get("value");
|
||||
|
||||
switch (op) {
|
||||
case "isNull":
|
||||
clauses.add(String.format("\"%s\" IS NULL", safeCol));
|
||||
break;
|
||||
case "isNotNull":
|
||||
clauses.add(String.format("\"%s\" IS NOT NULL", safeCol));
|
||||
break;
|
||||
case "in":
|
||||
case "notIn": {
|
||||
List<Object> list = toList(val);
|
||||
if (list.isEmpty()) continue;
|
||||
String marks = list.stream().map(v -> "?").collect(Collectors.joining(", "));
|
||||
String kw = "in".equals(op) ? "IN" : "NOT IN";
|
||||
clauses.add(String.format("\"%s\" %s (%s)", safeCol, kw, marks));
|
||||
values.addAll(list);
|
||||
break;
|
||||
}
|
||||
case "like":
|
||||
if (isEmptyAggregateFilterValue(val)) continue;
|
||||
clauses.add(String.format("\"%s\"::text ILIKE ?", safeCol));
|
||||
values.add("%" + val + "%");
|
||||
break;
|
||||
default:
|
||||
if (isEmptyAggregateFilterValue(val)) continue;
|
||||
clauses.add(String.format("\"%s\" %s ?", safeCol, op));
|
||||
values.add(val);
|
||||
}
|
||||
}
|
||||
return clauses.isEmpty() ? "" : "WHERE " + String.join(" AND ", clauses);
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> normalizeAggregateFilters(Object rawFilters) {
|
||||
if (!(rawFilters instanceof List<?> rawList) || rawList.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<Map<String, Object>> out = new ArrayList<>();
|
||||
for (Object item : rawList) {
|
||||
if (item instanceof Map<?, ?> rawMap) {
|
||||
Map<String, Object> normalized = new LinkedHashMap<>();
|
||||
for (Map.Entry<?, ?> entry : rawMap.entrySet()) {
|
||||
if (entry.getKey() instanceof String key) {
|
||||
normalized.put(key, entry.getValue());
|
||||
}
|
||||
}
|
||||
if (!normalized.isEmpty()) out.add(normalized);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private boolean isEmptyAggregateFilterValue(Object val) {
|
||||
if (val == null) return true;
|
||||
if (val instanceof String s) return s.isBlank();
|
||||
if (val instanceof Collection<?> c) return c.isEmpty();
|
||||
return false;
|
||||
}
|
||||
|
||||
private List<Object> toList(Object val) {
|
||||
if (val == null) return List.of();
|
||||
if (val instanceof List<?> l) {
|
||||
List<Object> out = new ArrayList<>();
|
||||
for (Object o : l) {
|
||||
if (o == null) continue;
|
||||
if (o instanceof String s && s.isBlank()) continue;
|
||||
out.add(o);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (val instanceof String s) {
|
||||
if (s.isBlank()) return List.of();
|
||||
return Arrays.stream(s.split(","))
|
||||
.map(String::trim)
|
||||
.filter(p -> !p.isEmpty())
|
||||
.map(p -> (Object) p)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
return List.of(val);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// 그룹별 집계 (Phase G.3 — canonical chart 용)
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* groupBy 컬럼별로 집계 결과 반환. canonical chart 컴포넌트가 bar / line / donut /
|
||||
* horizontalBar 모두에서 같은 endpoint 를 사용.
|
||||
*
|
||||
* body 예:
|
||||
* { "groupBy": "status", "aggregation": "count", "filters": [...] }
|
||||
* { "groupBy": "dept_code", "aggregation": "sum", "valueColumn": "amount", "limit": 12 }
|
||||
*
|
||||
* response:
|
||||
* { "rows": [{ "group": "재직", "value": 35 }, { "group": "휴직", "value": 4 }] }
|
||||
*/
|
||||
public Map<String, Object> aggregateTableGroup(String tableName, Map<String, Object> options) {
|
||||
String safeTable = sanitize(tableName);
|
||||
if (safeTable.isBlank() || !checkTableExists(safeTable)) {
|
||||
throw new IllegalArgumentException("테이블이 존재하지 않습니다: " + tableName);
|
||||
}
|
||||
|
||||
String groupBy = options.get("groupBy") instanceof String s ? s : null;
|
||||
String safeGroupBy = groupBy != null ? sanitize(groupBy) : "";
|
||||
if (safeGroupBy.isBlank() || !hasColumn(safeTable, safeGroupBy)) {
|
||||
throw new IllegalArgumentException("groupBy 컬럼이 존재하지 않습니다: " + tableName + "." + groupBy);
|
||||
}
|
||||
|
||||
String aggregation = options.get("aggregation") instanceof String s ? s : "count";
|
||||
if (!AGG_TYPES.contains(aggregation)) {
|
||||
throw new IllegalArgumentException("지원하지 않는 집계 타입: " + aggregation);
|
||||
}
|
||||
|
||||
String valueColumn = options.get("valueColumn") instanceof String s ? s : null;
|
||||
if (valueColumn == null && options.get("columnName") instanceof String s) valueColumn = s;
|
||||
String safeValueCol = valueColumn != null ? sanitize(valueColumn) : "";
|
||||
|
||||
boolean columnRequired = !"count".equals(aggregation);
|
||||
if (columnRequired) {
|
||||
if (safeValueCol.isBlank()) {
|
||||
throw new IllegalArgumentException(aggregation + " 은 valueColumn 이 필요합니다.");
|
||||
}
|
||||
if (!hasColumn(safeTable, safeValueCol)) {
|
||||
throw new IllegalArgumentException("valueColumn 이 존재하지 않습니다: " + tableName + "." + valueColumn);
|
||||
}
|
||||
} else if (!safeValueCol.isBlank() && !hasColumn(safeTable, safeValueCol)) {
|
||||
throw new IllegalArgumentException("valueColumn 이 존재하지 않습니다: " + tableName + "." + valueColumn);
|
||||
}
|
||||
|
||||
List<Map<String, Object>> filters = normalizeAggregateFilters(options.get("filters"));
|
||||
|
||||
int limit = toInt(options.get("limit"), 50);
|
||||
if (limit < 1) limit = 50;
|
||||
if (limit > 500) limit = 500;
|
||||
|
||||
String orderDir = options.get("orderDir") instanceof String s
|
||||
&& ("asc".equalsIgnoreCase(s) || "desc".equalsIgnoreCase(s))
|
||||
? s.toUpperCase()
|
||||
: "DESC";
|
||||
|
||||
List<Object> values = new ArrayList<>();
|
||||
String where = buildAggregateWhere(safeTable, filters, values);
|
||||
|
||||
String selectExpr;
|
||||
if ("count".equals(aggregation)) {
|
||||
selectExpr = !safeValueCol.isBlank()
|
||||
? String.format("COUNT(\"%s\")", safeValueCol)
|
||||
: "COUNT(*)";
|
||||
} else if ("distinctCount".equals(aggregation)) {
|
||||
selectExpr = String.format("COUNT(DISTINCT \"%s\")", safeValueCol);
|
||||
} else {
|
||||
String upper = aggregation.toUpperCase();
|
||||
if ("AVG".equals(upper) || "SUM".equals(upper)) {
|
||||
selectExpr = String.format("%s(CAST(\"%s\" AS NUMERIC))", upper, safeValueCol);
|
||||
} else {
|
||||
selectExpr = String.format("%s(\"%s\")", upper, safeValueCol);
|
||||
}
|
||||
}
|
||||
|
||||
String sql = String.format(
|
||||
"SELECT \"%s\" AS group_value, %s AS agg_value " +
|
||||
"FROM \"%s\" main %s " +
|
||||
"GROUP BY \"%s\" " +
|
||||
"ORDER BY agg_value %s NULLS LAST " +
|
||||
"LIMIT %d",
|
||||
safeGroupBy, selectExpr, safeTable, where, safeGroupBy, orderDir, limit);
|
||||
|
||||
List<Map<String, Object>> rawRows = jdbcTemplate.queryForList(sql, values.toArray());
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for (Map<String, Object> r : rawRows) {
|
||||
Object groupVal = r.get("group_value");
|
||||
Object aggVal = r.get("agg_value");
|
||||
double value = aggVal instanceof Number ? ((Number) aggVal).doubleValue() : 0d;
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("group", groupVal);
|
||||
out.put("value", value);
|
||||
rows.add(out);
|
||||
}
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("rows", rows);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// 가벼운 select-rows (Phase G.3.1 — card-list / grouped-table 용)
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* OptionFilter 호환 필터 + orderBy + limit/offset 로 임의 컬럼들의 row 들을 반환.
|
||||
* `getTableData` 는 페이지네이션 + ILIKE search 가 묶여 있어 view 컴포넌트가
|
||||
* 사용하기 무겁다. 본 메서드는 raw rows 만 깔끔하게 반환.
|
||||
*
|
||||
* body 예:
|
||||
* { "columns": ["user_name", "dept_code"], "filters": [...], "limit": 50 }
|
||||
* { "groupBy 없이 단순 다중 컬럼", "orderBy": [{ "column": "created_date", "direction": "desc" }] }
|
||||
*
|
||||
* response:
|
||||
* { "rows": [{...}, {...}] }
|
||||
*/
|
||||
public Map<String, Object> selectTableRows(String tableName, Map<String, Object> options) {
|
||||
String safeTable = sanitize(tableName);
|
||||
if (safeTable.isBlank() || !checkTableExists(safeTable)) {
|
||||
throw new IllegalArgumentException("테이블이 존재하지 않습니다: " + tableName);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Object> rawColumns = options.get("columns") instanceof List<?> raw
|
||||
? (List<Object>) raw : Collections.emptyList();
|
||||
|
||||
List<String> safeColumns = new ArrayList<>();
|
||||
for (Object c : rawColumns) {
|
||||
if (!(c instanceof String s)) continue;
|
||||
String safe = sanitize(s);
|
||||
if (safe.isBlank()) continue;
|
||||
if (!hasColumn(safeTable, safe)) continue;
|
||||
safeColumns.add(safe);
|
||||
}
|
||||
|
||||
String selectExpr;
|
||||
if (safeColumns.isEmpty()) {
|
||||
selectExpr = "main.*";
|
||||
} else {
|
||||
selectExpr = safeColumns.stream()
|
||||
.map(c -> "\"" + c + "\"")
|
||||
.collect(Collectors.joining(", "));
|
||||
}
|
||||
|
||||
List<Map<String, Object>> filters = normalizeAggregateFilters(options.get("filters"));
|
||||
|
||||
List<Object> values = new ArrayList<>();
|
||||
String where = buildAggregateWhere(safeTable, filters, values);
|
||||
|
||||
// orderBy: [{ column, direction }]
|
||||
List<Map<String, Object>> orderBy = normalizeAggregateFilters(options.get("orderBy"));
|
||||
|
||||
List<String> orderClauses = new ArrayList<>();
|
||||
for (Map<String, Object> ob : orderBy) {
|
||||
if (ob == null) continue;
|
||||
String col = ob.get("column") instanceof String s ? s : null;
|
||||
if (col == null) continue;
|
||||
String safeCol = sanitize(col);
|
||||
if (safeCol.isBlank() || !hasColumn(safeTable, safeCol)) continue;
|
||||
String dir = ob.get("direction") instanceof String s
|
||||
&& "desc".equalsIgnoreCase(s) ? "DESC" : "ASC";
|
||||
orderClauses.add(String.format("\"%s\" %s", safeCol, dir));
|
||||
}
|
||||
String order = "";
|
||||
if (!orderClauses.isEmpty()) {
|
||||
order = "ORDER BY " + String.join(", ", orderClauses);
|
||||
} else if (hasColumn(safeTable, "created_date")) {
|
||||
order = "ORDER BY main.created_date DESC";
|
||||
}
|
||||
|
||||
int limit = toInt(options.get("limit"), 50);
|
||||
if (limit < 1) limit = 50;
|
||||
if (limit > 500) limit = 500;
|
||||
int offset = toInt(options.get("offset"), 0);
|
||||
if (offset < 0) offset = 0;
|
||||
|
||||
String sql = String.format(
|
||||
"SELECT %s FROM \"%s\" main %s %s LIMIT %d OFFSET %d",
|
||||
selectExpr, safeTable, where, order, limit, offset);
|
||||
|
||||
List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql, values.toArray());
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("rows", rows);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> addTableData(String tableName, Map<String, Object> data) {
|
||||
String safeTable = sanitize(tableName);
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
// INVYONE 스튜디오 진입 페이지 (templates 테이블 기반)
|
||||
// - 템플릿 목록 + 새 템플릿 생성 → templates 테이블 CRUD
|
||||
// - URL ?id=<template_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 (
|
||||
<div className="ide-builder h-[calc(100vh-4rem)] w-full overflow-hidden bg-background">
|
||||
<ScreenDesigner
|
||||
<InvyoneStudio
|
||||
selectedScreen={selectedScreen}
|
||||
onBackToList={handleBackToList}
|
||||
onScreenUpdate={(updatedFields) => {
|
||||
|
||||
@@ -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 (
|
||||
<div className="fixed inset-0 z-50 bg-background">
|
||||
<ScreenDesigner
|
||||
<InvyoneStudio
|
||||
selectedScreen={selectedScreen}
|
||||
onBackToList={() => goToStep("list")}
|
||||
onScreenUpdate={(updatedFields) => {
|
||||
|
||||
+342
-5
@@ -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<ScreenDefinition>) => 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({
|
||||
}}
|
||||
/>
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
||||
{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 (
|
||||
<V2PropertiesPanel
|
||||
selectedComponent={tabComponentAsComponentData}
|
||||
tables={tables as any}
|
||||
onUpdateProperty={updateTabChildProperty}
|
||||
onDeleteComponent={deleteTabChild}
|
||||
currentTable={tables.length > 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 (
|
||||
<V2PropertiesPanel
|
||||
selectedComponent={panelComponentAsComponentData}
|
||||
tables={tables as any}
|
||||
onUpdateProperty={updatePanelChildProperty}
|
||||
onDeleteComponent={deletePanelChild}
|
||||
currentTable={tables.length > 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 ? (
|
||||
<V2PropertiesPanel
|
||||
selectedComponent={selectedComponent || undefined}
|
||||
tables={tables}
|
||||
@@ -79,7 +79,7 @@ import { screenApi } from "@/lib/api/screen";
|
||||
import { TransformWrapper, TransformComponent } from "react-zoom-pan-pinch";
|
||||
import { getNodeFlows, createNodeFlow, NodeFlow } from "@/lib/api/nodeFlows";
|
||||
import { FlowEditor } from "@/components/dataflow/node-editor/FlowEditor";
|
||||
import ScreenDesigner from "@/components/screen/ScreenDesigner";
|
||||
import InvyoneStudio from "@/components/screen/InvyoneStudio";
|
||||
import { TableSettingModal } from "@/components/screen/TableSettingModal";
|
||||
import {
|
||||
Select,
|
||||
@@ -701,11 +701,11 @@ export function ScreenSettingModal({
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ScreenDesigner 전체 화면 - Dialog 중첩 시 오버레이 컴포지팅으로 깜빡임 발생하여 직접 렌더링 */}
|
||||
{/* ScreenDesigner 자체 SlimToolbar에 "목록으로" 닫기 버튼이 있으므로 별도 X 버튼 불필요 */}
|
||||
{/* InvyoneStudio 전체 화면 - Dialog 중첩 시 오버레이 컴포지팅으로 깜빡임 발생하여 직접 렌더링 */}
|
||||
{/* InvyoneStudio 자체 SlimToolbar에 "목록으로" 닫기 버튼이 있으므로 별도 X 버튼 불필요 */}
|
||||
{showDesignerModal && (
|
||||
<div className="bg-background fixed inset-0 z-[1000] flex flex-col">
|
||||
<ScreenDesigner
|
||||
<InvyoneStudio
|
||||
selectedScreen={{
|
||||
screen_id: currentScreenId,
|
||||
screen_code: `screen_${currentScreenId}`,
|
||||
|
||||
@@ -3179,15 +3179,25 @@ export const ActionTab: React.FC<ButtonTabProps> = ({
|
||||
{/* 추가 데이터 제공 가능한 컴포넌트 (조건부 컨테이너, 셀렉트박스 등) */}
|
||||
{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 (
|
||||
<SelectItem key={comp.id} value={comp.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -386,14 +386,24 @@ export const DataTab: React.FC<DataTabProps> = ({
|
||||
</SelectItem>
|
||||
{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 (
|
||||
<SelectItem key={comp.id} value={comp.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -402,10 +402,6 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
||||
// 새로운 통합 입력 컴포넌트 (옛 V2 입력/선택은 input canonical 로 흡수되어 목록에서 제거됨)
|
||||
"input",
|
||||
"v2-entity-select",
|
||||
"v2-checkbox",
|
||||
"v2-radio",
|
||||
"v2-textarea",
|
||||
"v2-file",
|
||||
];
|
||||
|
||||
// 현재 컴포넌트가 입력 필드인지 확인
|
||||
|
||||
@@ -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: <span style={{ fontSize: 11 }}>✎</span> },
|
||||
{ id: "choice", name: "선택", icon: <span style={{ fontSize: 11 }}>☷</span> },
|
||||
{ id: "choice", name: "선택·태그", icon: <span style={{ fontSize: 11 }}>☷</span> },
|
||||
{ id: "auto", name: "자동", icon: <span style={{ fontSize: 11 }}>⚡</span> },
|
||||
{ id: "attach", name: "첨부", icon: <span style={{ fontSize: 11 }}>📎</span> },
|
||||
];
|
||||
@@ -76,8 +76,8 @@ const TYPES_BY_KIND: Record<Kind, (CPCrumbType & { id: Type })[]> = {
|
||||
{ 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<Type, CPFormatItem[]> = {
|
||||
{ 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 (
|
||||
<>
|
||||
<Hint>실제 동작은 v2-checkbox / boolean 컴포넌트로 위임. 라벨/표시 모드만 여기서.</Hint>
|
||||
<Hint>canonical input 이 직접 처리합니다. 표시 형태와 라벨만 설정합니다.</Hint>
|
||||
<CPRow label="표시 형태">
|
||||
<CPSegment
|
||||
value={config.boolStyle || "switch"}
|
||||
@@ -1867,7 +1870,7 @@ function FileOptions({
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Hint>실제 업로드 동작은 v2-file 컴포넌트로 위임. 정책/제한만 여기서.</Hint>
|
||||
<Hint>canonical input 의 파일 필드로 업로드합니다. 허용 형식과 제한만 설정합니다.</Hint>
|
||||
<CPRow label="허용 형식">
|
||||
<CPText
|
||||
mono
|
||||
@@ -2036,21 +2039,24 @@ function SelectAdvancedOptions({
|
||||
updateConfig: (k: string, v: any) => void;
|
||||
multi?: boolean;
|
||||
}) {
|
||||
const isTagsFormat = multi && config.format === "tags";
|
||||
|
||||
return (
|
||||
<>
|
||||
<CPRow label="선택 방식">
|
||||
<CPSelect value={config.mode || "dropdown"} onChange={(v) => updateConfig("mode", v)}>
|
||||
<option value="dropdown">드롭다운</option>
|
||||
<option value="combobox">검색 가능 드롭다운</option>
|
||||
{!multi && <option value="radio">라디오 버튼</option>}
|
||||
{multi && <option value="check">체크박스</option>}
|
||||
{multi && <option value="swap">스왑(좌우 이동)</option>}
|
||||
{multi && <option value="tag">태그 선택</option>}
|
||||
{!multi && <option value="toggle">토글</option>}
|
||||
</CPSelect>
|
||||
</CPRow>
|
||||
{!isTagsFormat && (
|
||||
<CPRow label="선택 방식">
|
||||
<CPSelect value={config.mode || "dropdown"} onChange={(v) => updateConfig("mode", v)}>
|
||||
<option value="dropdown">드롭다운</option>
|
||||
<option value="combobox">검색 가능 드롭다운</option>
|
||||
{!multi && <option value="radio">라디오 버튼</option>}
|
||||
{multi && <option value="check">체크박스</option>}
|
||||
{multi && <option value="swap">스왑(좌우 이동)</option>}
|
||||
{!multi && <option value="toggle">토글</option>}
|
||||
</CPSelect>
|
||||
</CPRow>
|
||||
)}
|
||||
{multi && (
|
||||
<CPRow label="최대 개수">
|
||||
<CPRow label={isTagsFormat ? "최대 태그 개수" : "최대 개수"}>
|
||||
<CPNumber
|
||||
value={config.maxSelect ?? ""}
|
||||
onChange={(v) => updateConfig("maxSelect", v)}
|
||||
@@ -2059,12 +2065,16 @@ function SelectAdvancedOptions({
|
||||
/>
|
||||
</CPRow>
|
||||
)}
|
||||
<CPRow label="검색 기능">
|
||||
<CPSwitch value={!!config.searchable} onChange={(v) => updateConfig("searchable", v)} />
|
||||
</CPRow>
|
||||
<CPRow label="선택 초기화">
|
||||
<CPSwitch value={config.allowClear !== false} onChange={(v) => updateConfig("allowClear", v)} />
|
||||
</CPRow>
|
||||
{!isTagsFormat && (
|
||||
<>
|
||||
<CPRow label="검색 기능">
|
||||
<CPSwitch value={!!config.searchable} onChange={(v) => updateConfig("searchable", v)} />
|
||||
</CPRow>
|
||||
<CPRow label="선택 초기화">
|
||||
<CPSwitch value={config.allowClear !== false} onChange={(v) => updateConfig("allowClear", v)} />
|
||||
</CPRow>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<Pick<OptionFilter, "column" | "operator" | "value">>;
|
||||
}
|
||||
|
||||
export interface AggregateResponse {
|
||||
value: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* aggregateTableStat — 단일 stat 카드 값을 백엔드에서 계산해 가져온다.
|
||||
*
|
||||
* 디자인 모드 / 빈 tableName 가드는 호출자가 책임진다.
|
||||
* 실패 시 axios error 가 그대로 throw 됨 — 상위 hook 에서 카드 단위 fallback.
|
||||
*/
|
||||
export async function aggregateTableStat(
|
||||
tableName: string,
|
||||
request: AggregateRequest,
|
||||
): Promise<AggregateResponse> {
|
||||
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 <groupBy>` 후 각 그룹마다 단일 집계 값을 계산해 row 배열로 반환.
|
||||
* `valueColumn` 는 `aggregation === "count"` 일 때 생략 가능. distinctCount / sum /
|
||||
* avg / min / max 는 valueColumn 필수 (백엔드 측 가드).
|
||||
*/
|
||||
export interface AggregateGroupRequest {
|
||||
aggregation: StatsAggregation;
|
||||
groupBy: string;
|
||||
valueColumn?: string;
|
||||
filters?: Array<Pick<OptionFilter, "column" | "operator" | "value">>;
|
||||
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<AggregateGroupResponse> {
|
||||
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<Pick<OptionFilter, "column" | "operator" | "value">>;
|
||||
orderBy?: SelectRowsOrderBy[];
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface SelectRowsResponse {
|
||||
rows: Record<string, any>[];
|
||||
}
|
||||
|
||||
/**
|
||||
* selectTableRows — multi-column row 들을 받아오는 가벼운 SELECT.
|
||||
*
|
||||
* 호출자는 디자인 모드 / 빈 tableName 가드를 책임진다. 실패는 axios error 로 throw.
|
||||
*/
|
||||
export async function selectTableRows(
|
||||
tableName: string,
|
||||
request: SelectRowsRequest,
|
||||
): Promise<SelectRowsResponse> {
|
||||
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<string, any>[])
|
||||
: [];
|
||||
return { rows: rawRows };
|
||||
}
|
||||
@@ -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<DynamicComponentProps, "web_type" | "props"> & {
|
||||
web_type?: string;
|
||||
@@ -13,6 +14,37 @@ type DynamicWebTypeRendererProps = Omit<DynamicComponentProps, "web_type" | "pro
|
||||
props?: Record<string, any>;
|
||||
};
|
||||
|
||||
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<DynamicWebTypeRendererProps> = ({
|
||||
};
|
||||
}, [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 (
|
||||
<InputComponent
|
||||
{...props}
|
||||
{...finalProps}
|
||||
config={{ ...fileConfig, ...mergedConfig }}
|
||||
config={{ ...canonicalConfig, ...fileConfig, ...mergedConfig, webType, web_type: webType }}
|
||||
/>
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("canonical input (file) 로드 실패:", error);
|
||||
console.error("canonical input 로드 실패:", error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ColumnPickerProps> = ({
|
||||
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 (
|
||||
<CPText
|
||||
value={value ?? ""}
|
||||
onChange={(v) => onChange(v || undefined)}
|
||||
placeholder={loading ? "컬럼 로딩 중…" : tableName ? "컬럼 없음" : placeholder}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CPSelect
|
||||
value={currentValid ? value ?? "" : (value as string)}
|
||||
onChange={(v) => onChange(v || undefined)}
|
||||
placeholder={loading ? "로딩…" : placeholder}
|
||||
>
|
||||
<option value="">{placeholder}</option>
|
||||
{!currentValid && value && (
|
||||
<option value={value}>{value} (사용자 정의)</option>
|
||||
)}
|
||||
{cols.map((c) => (
|
||||
<option key={c.value} value={c.value}>
|
||||
{c.label && c.label !== c.value
|
||||
? `${c.label} (${c.value})`
|
||||
: c.value}
|
||||
</option>
|
||||
))}
|
||||
</CPSelect>
|
||||
);
|
||||
};
|
||||
@@ -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<OptionFilter["value_type"]>; label: string }> = [
|
||||
{ value: "static", label: "고정값" },
|
||||
{ value: "field", label: "폼 필드" },
|
||||
{ value: "user", label: "사용자 컨텍스트" },
|
||||
];
|
||||
|
||||
const USER_FIELD_LABELS: Array<{ value: NonNullable<OptionFilter["user_field"]>; label: string }> = [
|
||||
{ value: "companyCode", label: "회사 코드" },
|
||||
{ value: "userId", label: "사용자 ID" },
|
||||
{ value: "deptCode", label: "부서 코드" },
|
||||
{ value: "userName", label: "사용자명" },
|
||||
];
|
||||
|
||||
export interface FilterRowProps {
|
||||
filter: OptionFilter;
|
||||
onChange: (p: Partial<OptionFilter>) => void;
|
||||
onRemove: () => void;
|
||||
/** 테이블 이름 — 있으면 컬럼 select 자동 채움 */
|
||||
tableName?: string;
|
||||
/** 외부에서 미리 받은 컬럼 (tableName 없을 때 fallback) */
|
||||
columnOptions?: DbColumnOption[];
|
||||
}
|
||||
|
||||
export const OptionFilterRow: React.FC<FilterRowProps> = ({
|
||||
filter,
|
||||
onChange,
|
||||
onRemove,
|
||||
tableName,
|
||||
columnOptions,
|
||||
}) => {
|
||||
useFilterRowKeyframesOnce();
|
||||
const valueType = filter.value_type ?? "static";
|
||||
const op = filter.operator ?? "=";
|
||||
const hideValue = op === "isNull" || op === "isNotNull";
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
border: "1px solid var(--cp-border-subtle)",
|
||||
borderRadius: 5,
|
||||
background: "var(--cp-bg-subtle)",
|
||||
padding: "8px 10px 8px 10px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 6,
|
||||
animation: "filter-row-fade-in .18s ease-out",
|
||||
}}
|
||||
>
|
||||
{/* 삭제 버튼 — 우측 상단 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
title="필터 삭제"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 6,
|
||||
right: 6,
|
||||
width: 20,
|
||||
height: 20,
|
||||
padding: 0,
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
cursor: "pointer",
|
||||
color: "var(--cp-text-muted)",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: 4,
|
||||
transition: "color .15s ease, background-color .15s ease",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
const b = e.currentTarget as HTMLButtonElement;
|
||||
b.style.color = "hsl(var(--destructive))";
|
||||
b.style.background = "hsl(var(--destructive) / 0.10)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
const b = e.currentTarget as HTMLButtonElement;
|
||||
b.style.color = "var(--cp-text-muted)";
|
||||
b.style.background = "transparent";
|
||||
}}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
|
||||
{/* row 1: 컬럼 */}
|
||||
<FieldRow label="컬럼">
|
||||
<ColumnPicker
|
||||
tableName={tableName}
|
||||
columnOptions={columnOptions}
|
||||
value={filter.column}
|
||||
onChange={(v) => onChange({ column: v ?? "" })}
|
||||
placeholder="컬럼 선택…"
|
||||
/>
|
||||
</FieldRow>
|
||||
|
||||
{/* row 2: 조건 + (필요시) 값 종류 */}
|
||||
<FieldRow label="조건">
|
||||
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
|
||||
<div style={{ flex: "1 1 140px", minWidth: 120 }}>
|
||||
<CPSelect
|
||||
value={op}
|
||||
onChange={(v) => onChange({ operator: v as OptionFilter["operator"] })}
|
||||
>
|
||||
{FILTER_OP_LABELS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</CPSelect>
|
||||
</div>
|
||||
{!hideValue && (
|
||||
<div style={{ flex: "1 1 120px", minWidth: 100 }}>
|
||||
<CPSelect
|
||||
value={valueType}
|
||||
onChange={(v) =>
|
||||
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) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</CPSelect>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FieldRow>
|
||||
|
||||
{/* row 3: 값 (hideValue 면 안 보임) */}
|
||||
{!hideValue && (
|
||||
<FieldRow label="값">
|
||||
{valueType === "static" ? (
|
||||
<CPText
|
||||
value={(filter.value as string | undefined) ?? ""}
|
||||
onChange={(v) => onChange({ value: v })}
|
||||
placeholder={
|
||||
op === "in" || op === "notIn"
|
||||
? "쉼표로 구분 — 예: 재직, 휴직, 퇴사"
|
||||
: "고정값 입력 — 예: 재직"
|
||||
}
|
||||
/>
|
||||
) : valueType === "field" ? (
|
||||
<CPText
|
||||
value={filter.field_ref ?? ""}
|
||||
onChange={(v) => onChange({ field_ref: v })}
|
||||
placeholder="폼 필드명 — 화면의 어떤 입력값을 따라갈지"
|
||||
/>
|
||||
) : (
|
||||
<CPSelect
|
||||
value={filter.user_field ?? "companyCode"}
|
||||
onChange={(v) => onChange({ user_field: v as OptionFilter["user_field"] })}
|
||||
>
|
||||
{USER_FIELD_LABELS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</CPSelect>
|
||||
)}
|
||||
</FieldRow>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ───────────────────────────────────────────────────────
|
||||
// FieldRow — 라벨 + 값 한 줄 (cp Row 톤, label 폭 고정)
|
||||
// ───────────────────────────────────────────────────────
|
||||
function FieldRow({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "40px minmax(0, 1fr)",
|
||||
alignItems: "center",
|
||||
columnGap: 8,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10.5,
|
||||
fontWeight: 700,
|
||||
color: "var(--cp-text-sec)",
|
||||
letterSpacing: "-0.005em",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<div style={{ minWidth: 0 }}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
userContext?: {
|
||||
companyCode?: string;
|
||||
userId?: string;
|
||||
deptCode?: string;
|
||||
userName?: string;
|
||||
};
|
||||
refreshKey?: number | string;
|
||||
/** preview 모드의 가짜 row 개수 (기본 4) */
|
||||
previewCount?: number;
|
||||
/** preview row 생성기 — 컴포넌트별 맞춤 가능 */
|
||||
previewBuilder?: (index: number) => Record<string, any>;
|
||||
}
|
||||
|
||||
export interface TableRowsState {
|
||||
rows: Record<string, any>[];
|
||||
loading: boolean;
|
||||
error?: string;
|
||||
/** dataSource 미완성으로 preview 만 보여주는 중인지 */
|
||||
isPreview: boolean;
|
||||
}
|
||||
|
||||
function defaultPreview(count: number): Record<string, any>[] {
|
||||
const out: Record<string, any>[] = [];
|
||||
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<string, unknown> | 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<Record<string, any>[]>(() => {
|
||||
const n = Math.max(2, Math.min(12, previewCount ?? 4));
|
||||
if (previewBuilder) {
|
||||
const out: Record<string, any>[] = [];
|
||||
for (let i = 0; i < n; i++) out.push(previewBuilder(i));
|
||||
return out;
|
||||
}
|
||||
return defaultPreview(n);
|
||||
}, [previewCount, previewBuilder]);
|
||||
|
||||
const [state, setState] = useState<TableRowsState>({
|
||||
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<Map<string, Record<string, any>[]>>(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;
|
||||
}
|
||||
@@ -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<CardListComponentProps> = ({
|
||||
component,
|
||||
isDesignMode = false,
|
||||
isSelected = false,
|
||||
onClick,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
config,
|
||||
className,
|
||||
style,
|
||||
...props
|
||||
}) => {
|
||||
const p = props as any;
|
||||
const fromProps: Partial<CardListConfig> = {};
|
||||
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<string, unknown>) || 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<string, any>, idx: number) => {
|
||||
const titleText = titleField ? cellToText(row[titleField]) : `행 ${idx + 1}`;
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
style={{
|
||||
padding: "8px 10px",
|
||||
border: "1px solid hsl(var(--border))",
|
||||
borderRadius: 6,
|
||||
background: "hsl(var(--card))",
|
||||
display: "flex",
|
||||
flexDirection: layout === "grid" ? "column" : "row",
|
||||
alignItems: layout === "grid" ? "stretch" : "center",
|
||||
gap: 8,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
color: "hsl(var(--foreground))",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{titleText}
|
||||
</div>
|
||||
{subtitleFields.map((col, sIdx) => (
|
||||
<div
|
||||
key={sIdx}
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "hsl(var(--muted-foreground))",
|
||||
marginTop: 2,
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{cellToText(row[col])}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{metricFields.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 6,
|
||||
flexShrink: 0,
|
||||
flexWrap: "wrap",
|
||||
justifyContent: layout === "grid" ? "flex-start" : "flex-end",
|
||||
}}
|
||||
>
|
||||
{metricFields.map((m, mIdx) => {
|
||||
const v = cellToText(row[m.column]);
|
||||
return (
|
||||
<div
|
||||
key={mIdx}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "flex-end",
|
||||
padding: "3px 8px",
|
||||
borderRadius: 4,
|
||||
background: m.emphasis
|
||||
? "rgba(var(--v5-primary-rgb), 0.12)"
|
||||
: "hsl(var(--muted))",
|
||||
border: "1px solid hsl(var(--border))",
|
||||
}}
|
||||
>
|
||||
{m.label && (
|
||||
<span
|
||||
style={{ fontSize: 9, color: "hsl(var(--muted-foreground))" }}
|
||||
>
|
||||
{m.label}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
color: m.emphasis
|
||||
? "var(--v5-primary, hsl(var(--primary)))"
|
||||
: "hsl(var(--foreground))",
|
||||
}}
|
||||
>
|
||||
{v}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={containerStyle}
|
||||
className={className}
|
||||
onClick={handleClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
{...filterDOMProps(domProps)}
|
||||
>
|
||||
{(title || isPreview || loading) && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
color: "hsl(var(--foreground))",
|
||||
marginBottom: 6,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<span>{title || (isPreview ? "카드 리스트 (미리보기)" : "")}</span>
|
||||
{loading && (
|
||||
<span style={{ fontSize: 10, color: "hsl(var(--muted-foreground))", fontWeight: 500 }}>
|
||||
로딩…
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div style={listContainerStyle}>
|
||||
{error ? (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flex: 1,
|
||||
fontSize: 12,
|
||||
color: "hsl(var(--muted-foreground))",
|
||||
}}
|
||||
>
|
||||
데이터 로드 실패: {error}
|
||||
</div>
|
||||
) : rows.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flex: 1,
|
||||
fontSize: 12,
|
||||
color: "hsl(var(--muted-foreground))",
|
||||
}}
|
||||
>
|
||||
{emptyText}
|
||||
</div>
|
||||
) : (
|
||||
rows.map((row, idx) => renderCard(row, idx))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const CardListWrapper: React.FC<CardListComponentProps> = (props) => {
|
||||
return <CardListComponent {...props} />;
|
||||
};
|
||||
@@ -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 <CardListComponent {...this.props} renderer={this} />;
|
||||
}
|
||||
}
|
||||
|
||||
CardListRenderer.registerSelf();
|
||||
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
CardListRenderer.enableHotReload();
|
||||
}
|
||||
@@ -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<InvCardListConfigPanelProps> = ({
|
||||
config,
|
||||
onChange,
|
||||
selectedComponent,
|
||||
}) => {
|
||||
const current: CardListConfig =
|
||||
(config as CardListConfig) || (selectedComponent?.config as CardListConfig) || {};
|
||||
const patch = (p: Partial<CardListConfig>) => onChange?.({ ...current, ...p });
|
||||
|
||||
const ds = current.dataSource ?? {};
|
||||
const patchDataSource = (p: Partial<CardListDataSource>) => {
|
||||
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<OptionFilter>) => {
|
||||
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<CardListMetric>) => {
|
||||
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 (
|
||||
<div style={{ fontFamily: "var(--v5-font-sans)", color: "var(--cp-text)", padding: "0 12px" }}>
|
||||
{/* ── ① 기본 ─────────────────────────── */}
|
||||
<CPSection title="① 기본">
|
||||
<CPRow label="제목" help="비우면 제목 영역 숨김">
|
||||
<CPText
|
||||
value={current.title || ""}
|
||||
onChange={(v) => patch({ title: v || undefined })}
|
||||
placeholder="부서별 카드"
|
||||
/>
|
||||
</CPRow>
|
||||
<CPRow label="레이아웃">
|
||||
<CPSegment
|
||||
value={current.layout || "list"}
|
||||
onChange={(v) => patch({ layout: v as CardListLayout })}
|
||||
options={[
|
||||
{ value: "list", label: "리스트" },
|
||||
{ value: "grid", label: "그리드" },
|
||||
]}
|
||||
/>
|
||||
</CPRow>
|
||||
{(current.layout ?? "list") === "grid" && (
|
||||
<CPRow label="그리드 컬럼" help="비우면 auto-fit. 1 ~ 6 권장">
|
||||
<CPNumber
|
||||
value={current.columns ?? undefined}
|
||||
onChange={(v) => patch({ columns: v ?? undefined })}
|
||||
min={1}
|
||||
max={8}
|
||||
placeholder="auto"
|
||||
/>
|
||||
</CPRow>
|
||||
)}
|
||||
<CPRow label="빈 결과 문구">
|
||||
<CPText
|
||||
value={current.emptyText || ""}
|
||||
onChange={(v) => patch({ emptyText: v || undefined })}
|
||||
placeholder="데이터 없음"
|
||||
/>
|
||||
</CPRow>
|
||||
</CPSection>
|
||||
|
||||
{/* ── ② 데이터 소스 ─────────────────────────── */}
|
||||
<CPSection title="② 데이터 소스">
|
||||
<CPRow label="테이블">
|
||||
<CPSelect
|
||||
value={ds.tableName || ""}
|
||||
onChange={(v) => patchDataSource({ tableName: v || undefined })}
|
||||
>
|
||||
<option value="">선택...</option>
|
||||
{tableOptions.map((t) => (
|
||||
<option key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
</CPSelect>
|
||||
</CPRow>
|
||||
<CPRow label="정렬 컬럼" help="비우면 created_date desc (있을 때) 또는 PK 순">
|
||||
<CPText
|
||||
value={orderByCol}
|
||||
onChange={(v) =>
|
||||
patchDataSource({
|
||||
orderBy: v
|
||||
? [{ column: v, direction: orderByDir as "asc" | "desc" }]
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
placeholder="created_date"
|
||||
/>
|
||||
</CPRow>
|
||||
{orderByCol && (
|
||||
<CPRow label="정렬 방향">
|
||||
<CPSegment
|
||||
value={orderByDir}
|
||||
onChange={(v) =>
|
||||
patchDataSource({
|
||||
orderBy: [{ column: orderByCol, direction: v as "asc" | "desc" }],
|
||||
})
|
||||
}
|
||||
options={[
|
||||
{ value: "desc", label: "내림차순" },
|
||||
{ value: "asc", label: "오름차순" },
|
||||
]}
|
||||
/>
|
||||
</CPRow>
|
||||
)}
|
||||
<CPRow label="최대 행 수" help="1 ~ 500. 기본 50">
|
||||
<CPNumber
|
||||
value={ds.limit ?? 50}
|
||||
onChange={(v) => patchDataSource({ limit: v ?? 50 })}
|
||||
min={1}
|
||||
max={500}
|
||||
/>
|
||||
</CPRow>
|
||||
</CPSection>
|
||||
|
||||
{/* ── ③ 필드 매핑 ─────────────────────────── */}
|
||||
<CPSection title="③ 필드 매핑" desc="카드 한 장에 보일 컬럼들">
|
||||
<CPRow label="제목 컬럼">
|
||||
<CPText
|
||||
value={current.titleField || ""}
|
||||
onChange={(v) => patch({ titleField: v || undefined })}
|
||||
placeholder="dept_name"
|
||||
/>
|
||||
</CPRow>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginTop: 6,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 10.5, color: "var(--cp-text-sec)", fontWeight: 700 }}>
|
||||
부제 컬럼 {subtitles.length > 0 && `(${subtitles.length})`}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addSubtitle}
|
||||
style={{
|
||||
padding: "2px 6px",
|
||||
fontSize: 10,
|
||||
background: "var(--cp-bg-subtle)",
|
||||
border: "1px solid var(--cp-border)",
|
||||
borderRadius: 3,
|
||||
cursor: "pointer",
|
||||
color: "var(--cp-text)",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 3,
|
||||
}}
|
||||
>
|
||||
<Plus size={9} /> 추가
|
||||
</button>
|
||||
</div>
|
||||
{subtitles.length === 0 ? (
|
||||
<Hint>부제 없음</Hint>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 4, marginTop: 4 }}>
|
||||
{subtitles.map((col, idx) => (
|
||||
<SubtitleRow
|
||||
key={idx}
|
||||
value={col}
|
||||
onChange={(v) => updateSubtitle(idx, v)}
|
||||
onRemove={() => removeSubtitle(idx)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginTop: 10,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 10.5, color: "var(--cp-text-sec)", fontWeight: 700 }}>
|
||||
metric 칩 {metrics.length > 0 && `(${metrics.length})`}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addMetric}
|
||||
style={{
|
||||
padding: "2px 6px",
|
||||
fontSize: 10,
|
||||
background: "var(--cp-bg-subtle)",
|
||||
border: "1px solid var(--cp-border)",
|
||||
borderRadius: 3,
|
||||
cursor: "pointer",
|
||||
color: "var(--cp-text)",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 3,
|
||||
}}
|
||||
>
|
||||
<Plus size={9} /> 추가
|
||||
</button>
|
||||
</div>
|
||||
{metrics.length === 0 ? (
|
||||
<Hint>metric 칩 없음 — 카드 우측/하단 숫자 강조 영역 빈 상태</Hint>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 4, marginTop: 4 }}>
|
||||
{metrics.map((m, idx) => (
|
||||
<MetricRow
|
||||
key={idx}
|
||||
metric={m}
|
||||
onChange={(p) => updateMetric(idx, p)}
|
||||
onRemove={() => removeMetric(idx)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CPSection>
|
||||
|
||||
{/* ── ④ 필터 ─────────────────────────── */}
|
||||
<CPSection title={`④ 필터 ${filters.length > 0 ? `(${filters.length})` : ""}`}>
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", marginBottom: 5 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addFilter}
|
||||
style={{
|
||||
padding: "3px 8px",
|
||||
fontSize: 10.5,
|
||||
background: "var(--cp-bg-subtle)",
|
||||
border: "1px solid var(--cp-border)",
|
||||
borderRadius: 4,
|
||||
cursor: "pointer",
|
||||
color: "var(--cp-text)",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 3,
|
||||
}}
|
||||
>
|
||||
<Plus size={10} /> 추가
|
||||
</button>
|
||||
</div>
|
||||
{filters.length === 0 ? (
|
||||
<Hint>필터 없음 — 테이블 전체 row</Hint>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
{filters.map((f, idx) => (
|
||||
<OptionFilterRow
|
||||
key={idx}
|
||||
filter={f}
|
||||
onChange={(p) => updateFilter(idx, p)}
|
||||
onRemove={() => removeFilter(idx)}
|
||||
tableName={ds.tableName}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CPSection>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
InvCardListConfigPanel.displayName = "InvCardListConfigPanel";
|
||||
|
||||
function SubtitleRow({
|
||||
value,
|
||||
onChange,
|
||||
onRemove,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 18px", gap: 4, alignItems: "center" }}>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => 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,
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
style={{
|
||||
width: 18,
|
||||
height: 18,
|
||||
padding: 0,
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
cursor: "pointer",
|
||||
color: "var(--cp-text-muted)",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
aria-label="삭제"
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricRow({
|
||||
metric,
|
||||
onChange,
|
||||
onRemove,
|
||||
}: {
|
||||
metric: CardListMetric;
|
||||
onChange: (p: Partial<CardListMetric>) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr 60px 18px",
|
||||
gap: 4,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={metric.column}
|
||||
onChange={(e) => onChange({ column: e.target.value })}
|
||||
placeholder="컬럼명"
|
||||
style={inputBase()}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={metric.label || ""}
|
||||
onChange={(e) => onChange({ label: e.target.value || undefined })}
|
||||
placeholder="라벨"
|
||||
style={inputBase()}
|
||||
/>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 3 }}>
|
||||
<CPSwitch value={!!metric.emphasis} onChange={(v) => onChange({ emphasis: v })} />
|
||||
<span style={{ fontSize: 9, color: "var(--cp-text-muted)" }}>강조</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
style={{
|
||||
width: 18,
|
||||
height: 18,
|
||||
padding: 0,
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
cursor: "pointer",
|
||||
color: "var(--cp-text-muted)",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
aria-label="metric 삭제"
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -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<CardListConfig> = {
|
||||
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<string, any>,
|
||||
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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<ChartComponentProps> = ({
|
||||
component,
|
||||
isDesignMode = false,
|
||||
isSelected = false,
|
||||
onClick,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
config,
|
||||
className,
|
||||
style,
|
||||
...props
|
||||
}) => {
|
||||
const p = props as any;
|
||||
const fromProps: Partial<ChartConfig> = {};
|
||||
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<string, unknown>) || 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 (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flex: 1,
|
||||
color: "hsl(var(--muted-foreground))",
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{error ? `데이터 로드 실패: ${error}` : "표시할 데이터가 없습니다."}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
switch (chartType) {
|
||||
case "donut":
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
innerRadius="55%"
|
||||
outerRadius="85%"
|
||||
paddingAngle={2}
|
||||
>
|
||||
{data.map((_, i) => (
|
||||
<Cell key={i} fill={donutPalette[i % donutPalette.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
{showLegend && <Legend verticalAlign="bottom" height={24} />}
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
case "horizontalBar":
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={data} layout="vertical" margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
|
||||
{showAxis && <XAxis type="number" stroke="hsl(var(--muted-foreground))" fontSize={11} />}
|
||||
{showAxis && (
|
||||
<YAxis dataKey="name" type="category" stroke="hsl(var(--muted-foreground))" fontSize={11} width={80} />
|
||||
)}
|
||||
<Tooltip />
|
||||
<Bar dataKey="value" fill={barColor} radius={[0, 4, 4, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
case "line":
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={data} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
|
||||
{showAxis && <XAxis dataKey="name" stroke="hsl(var(--muted-foreground))" fontSize={11} />}
|
||||
{showAxis && <YAxis stroke="hsl(var(--muted-foreground))" fontSize={11} />}
|
||||
<Tooltip />
|
||||
<Line type="monotone" dataKey="value" stroke={barColor} strokeWidth={2} dot={{ r: 3 }} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
case "bar":
|
||||
default:
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={data} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
|
||||
{showAxis && <XAxis dataKey="name" stroke="hsl(var(--muted-foreground))" fontSize={11} />}
|
||||
{showAxis && <YAxis stroke="hsl(var(--muted-foreground))" fontSize={11} />}
|
||||
<Tooltip />
|
||||
<Bar dataKey="value" fill={barColor} radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={containerStyle}
|
||||
className={className}
|
||||
onClick={handleClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
{...filterDOMProps(domProps)}
|
||||
>
|
||||
{(title || isPreview || loading) && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
color: "hsl(var(--foreground))",
|
||||
marginBottom: 6,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<span>{title || (isPreview ? "차트 (미리보기)" : "")}</span>
|
||||
{loading && (
|
||||
<span style={{ fontSize: 10, color: "hsl(var(--muted-foreground))", fontWeight: 500 }}>
|
||||
로딩…
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ flex: 1, minHeight: 0 }}>{renderChart()}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ChartWrapper: React.FC<ChartComponentProps> = (props) => {
|
||||
return <ChartComponent {...props} />;
|
||||
};
|
||||
@@ -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 <ChartComponent {...this.props} renderer={this} />;
|
||||
}
|
||||
}
|
||||
|
||||
ChartRenderer.registerSelf();
|
||||
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
ChartRenderer.enableHotReload();
|
||||
}
|
||||
@@ -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: <BarChart3 size={18} />, desc: "세로 막대 그래프" },
|
||||
{ value: "horizontalBar", label: "가로 막대", preview: <BarChart2 size={18} style={{ transform: "rotate(90deg)" }} />, desc: "가로 막대 그래프" },
|
||||
{ value: "line", label: "선", preview: <LineIcon size={18} />, desc: "추세선" },
|
||||
{ value: "donut", label: "도넛", preview: <PieIcon size={18} />, 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<InvChartConfigPanelProps> = ({
|
||||
config,
|
||||
onChange,
|
||||
selectedComponent,
|
||||
}) => {
|
||||
const current: ChartConfig =
|
||||
(config as ChartConfig) || (selectedComponent?.config as ChartConfig) || {};
|
||||
const patch = (p: Partial<ChartConfig>) => onChange?.({ ...current, ...p });
|
||||
|
||||
const ds = current.dataSource ?? {};
|
||||
const patchDataSource = (p: Partial<ChartDataSource>) => {
|
||||
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<OptionFilter>) => {
|
||||
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 (
|
||||
<div style={{ fontFamily: "var(--v5-font-sans)", color: "var(--cp-text)", padding: "0 12px" }}>
|
||||
{/* ── ① 기본 ─────────────────────────── */}
|
||||
<CPSection title="① 기본">
|
||||
<CPRow label="제목" help="비우면 제목 영역 숨김">
|
||||
<CPText
|
||||
value={current.title || ""}
|
||||
onChange={(v) => patch({ title: v || undefined })}
|
||||
placeholder="부서별 인원"
|
||||
/>
|
||||
</CPRow>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<CPRow label="차트 종류" />
|
||||
<CPVisualGrid
|
||||
cols={4}
|
||||
cardHeight={56}
|
||||
value={current.chartType || "bar"}
|
||||
onChange={(v) => patch({ chartType: v as ChartType })}
|
||||
options={CHART_TYPES}
|
||||
/>
|
||||
</div>
|
||||
</CPSection>
|
||||
|
||||
{/* ── ② 데이터 소스 ─────────────────────────── */}
|
||||
<CPSection title="② 데이터 소스" desc="GROUP BY 컬럼별 집계 결과를 차트로 표시">
|
||||
<CPRow label="테이블">
|
||||
<CPSelect
|
||||
value={ds.tableName || ""}
|
||||
onChange={(v) => patchDataSource({ tableName: v || undefined })}
|
||||
>
|
||||
<option value="">선택...</option>
|
||||
{tableOptions.map((t) => (
|
||||
<option key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
</CPSelect>
|
||||
</CPRow>
|
||||
<CPRow label="그룹 컬럼" help="이 컬럼 값별로 묶어 집계">
|
||||
<ColumnPicker
|
||||
tableName={ds.tableName}
|
||||
value={ds.groupBy}
|
||||
onChange={(v) => patchDataSource({ groupBy: v || undefined })}
|
||||
placeholder="그룹 컬럼 선택…"
|
||||
/>
|
||||
</CPRow>
|
||||
<CPRow label="집계">
|
||||
<CPSelect
|
||||
value={aggregation}
|
||||
onChange={(v) => patchDataSource({ aggregation: (v as StatsAggregation) || "count" })}
|
||||
>
|
||||
{AGGREGATION_OPTS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</CPSelect>
|
||||
</CPRow>
|
||||
{(needsValueColumn || ds.valueColumn) && (
|
||||
<CPRow label="값 컬럼" help={needsValueColumn ? "필수" : "(count 는 비워도 됨)"}>
|
||||
<ColumnPicker
|
||||
tableName={ds.tableName}
|
||||
value={ds.valueColumn}
|
||||
onChange={(v) => patchDataSource({ valueColumn: v || undefined })}
|
||||
placeholder="값 컬럼 선택…"
|
||||
/>
|
||||
</CPRow>
|
||||
)}
|
||||
</CPSection>
|
||||
|
||||
{/* ── ③ 필터 ─────────────────────────── */}
|
||||
<CPSection title={`③ 필터 ${filters.length > 0 ? `(${filters.length})` : ""}`}>
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", marginBottom: 5 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addFilter}
|
||||
style={{
|
||||
padding: "3px 8px",
|
||||
fontSize: 10.5,
|
||||
background: "var(--cp-bg-subtle)",
|
||||
border: "1px solid var(--cp-border)",
|
||||
borderRadius: 4,
|
||||
cursor: "pointer",
|
||||
color: "var(--cp-text)",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 3,
|
||||
}}
|
||||
>
|
||||
<Plus size={10} /> 추가
|
||||
</button>
|
||||
</div>
|
||||
{filters.length === 0 ? (
|
||||
<Hint>필터 없음 — 테이블 전체 그룹별 집계</Hint>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
{filters.map((f, idx) => (
|
||||
<OptionFilterRow
|
||||
key={idx}
|
||||
filter={f}
|
||||
onChange={(p) => updateFilter(idx, p)}
|
||||
onRemove={() => removeFilter(idx)}
|
||||
tableName={ds.tableName}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CPSection>
|
||||
|
||||
{/* ── ④ 표시 옵션 ─────────────────────────── */}
|
||||
<CPSection title="④ 표시 옵션">
|
||||
<CPRow label="최대 그룹 수" help="1 ~ 500. 기본 12">
|
||||
<CPNumber
|
||||
value={ds.limit ?? 12}
|
||||
onChange={(v) => patchDataSource({ limit: v ?? 12 })}
|
||||
min={1}
|
||||
max={500}
|
||||
/>
|
||||
</CPRow>
|
||||
<CPRow label="정렬">
|
||||
<CPSegment
|
||||
value={ds.orderDir ?? "desc"}
|
||||
onChange={(v) => patchDataSource({ orderDir: v as "asc" | "desc" })}
|
||||
options={[
|
||||
{ value: "desc", label: "값 큰 순" },
|
||||
{ value: "asc", label: "값 작은 순" },
|
||||
]}
|
||||
/>
|
||||
</CPRow>
|
||||
<CPRow label="빈 그룹 포함">
|
||||
<CPSwitch
|
||||
value={!!current.includeEmptyGroup}
|
||||
onChange={(v) => patch({ includeEmptyGroup: v })}
|
||||
/>
|
||||
</CPRow>
|
||||
<CPRow label="축 표시">
|
||||
<CPSwitch
|
||||
value={current.showAxis ?? true}
|
||||
onChange={(v) => patch({ showAxis: v })}
|
||||
/>
|
||||
</CPRow>
|
||||
{(current.chartType ?? "bar") === "donut" && (
|
||||
<CPRow label="범례 표시">
|
||||
<CPSwitch
|
||||
value={current.showLegend ?? true}
|
||||
onChange={(v) => patch({ showLegend: v })}
|
||||
/>
|
||||
</CPRow>
|
||||
)}
|
||||
{(current.chartType ?? "bar") !== "donut" && (
|
||||
<CPRow label="막대/선 색">
|
||||
<CPColor
|
||||
value={current.barColor || "#6c5ce7"}
|
||||
onChange={(v) => patch({ barColor: v || undefined })}
|
||||
/>
|
||||
</CPRow>
|
||||
)}
|
||||
</CPSection>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
InvChartConfigPanel.displayName = "InvChartConfigPanel";
|
||||
|
||||
export default InvChartConfigPanel;
|
||||
@@ -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<ChartConfig> = {
|
||||
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<string, any>,
|
||||
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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
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<string, unknown> | 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<AggregateGroupRow[]>(() => {
|
||||
const n = previewCount ?? DEFAULT_PREVIEW.length;
|
||||
return DEFAULT_PREVIEW.slice(0, Math.max(2, Math.min(8, n)));
|
||||
}, [previewCount]);
|
||||
|
||||
const [state, setState] = useState<ChartDataState>({
|
||||
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<Map<string, AggregateGroupRow[]>>(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;
|
||||
}
|
||||
@@ -33,6 +33,262 @@ const POPULAR = [
|
||||
"BarChart3", "PieChart", "TrendingUp", "DollarSign", "ShoppingCart", "Package",
|
||||
];
|
||||
|
||||
/**
|
||||
* 한글 키워드 → 아이콘 매핑 (검색 시 alias 로 사용).
|
||||
* lucide 영문 이름과 함께 매치. 자주 쓰일만한 도메인 키워드 위주.
|
||||
*/
|
||||
const ICON_KO: Record<string, string[]> = {
|
||||
// 사용자 / 사원
|
||||
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<IconPickerProps> = ({ value, onChange, classNa
|
||||
const [search, setSearch] = useState("");
|
||||
const [focused, setFocused] = useState(false);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const popoverRef = useRef<HTMLDivElement>(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<IconPickerProps> = ({ 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<IconPickerProps> = ({ 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<IconPickerProps> = ({ 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<IconPickerProps> = ({ value, onChange, classNa
|
||||
|
||||
{open && popoverPos && createPortal(
|
||||
<div
|
||||
ref={popoverRef}
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: popoverPos.top,
|
||||
left: popoverPos.left,
|
||||
width: popoverPos.width,
|
||||
height: popoverPos.maxHeight,
|
||||
background: "var(--cp-surface)",
|
||||
border: "1px solid var(--cp-border)",
|
||||
borderRadius: 6,
|
||||
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.12)",
|
||||
zIndex: 9999,
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
{/* 검색 */}
|
||||
@@ -175,7 +461,7 @@ export const IconPicker: React.FC<IconPickerProps> = ({ 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<IconPickerProps> = ({ value, onChange, classNa
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 그리드 */}
|
||||
{/* 그리드 — popover 안에서 flex 로 늘어나며 휠 스크롤 활성 */}
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(6, 1fr)",
|
||||
gap: 2,
|
||||
padding: 6,
|
||||
maxHeight: 200,
|
||||
overflowY: "auto",
|
||||
minHeight: 0,
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{/* 선택 해제 */}
|
||||
@@ -239,11 +526,11 @@ export const IconPicker: React.FC<IconPickerProps> = ({ 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",
|
||||
|
||||
@@ -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<string, DbColumnOption[]>();
|
||||
const inflight = new Map<string, Promise<DbColumnOption[]>>();
|
||||
|
||||
export interface UseDbColumnsResult {
|
||||
columns: DbColumnOption[];
|
||||
loading: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function useDbColumns(tableName?: string): UseDbColumnsResult {
|
||||
const [columns, setColumns] = useState<DbColumnOption[]>(() =>
|
||||
tableName ? cache.get(tableName) ?? [] : [],
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | undefined>(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]);
|
||||
}
|
||||
@@ -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<ContainerComponentProps> = ({
|
||||
{ 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 (
|
||||
<>
|
||||
<div style={{
|
||||
display: "flex", gap: "0", borderBottom: "1px solid hsl(var(--border))",
|
||||
background: "hsl(var(--muted))", flexShrink: 0,
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "0",
|
||||
borderBottom: "1px solid hsl(var(--border))",
|
||||
background: "hsl(var(--muted))",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<button key={tab.id} type="button" onClick={() => !isDesignMode && setActiveTab(tab.id)}
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSelect(tab.id);
|
||||
}}
|
||||
style={{
|
||||
padding: "8px 16px", fontSize: "12px", fontWeight: activeTab === tab.id ? 700 : 500,
|
||||
color: activeTab === tab.id ? "hsl(var(--primary))" : "hsl(var(--muted-foreground))",
|
||||
background: activeTab === tab.id ? "hsl(var(--card))" : "transparent",
|
||||
border: "none", borderBottom: activeTab === tab.id ? "2px solid hsl(var(--primary))" : "2px solid transparent",
|
||||
cursor: isDesignMode ? "default" : "pointer",
|
||||
}}>
|
||||
padding: "8px 16px",
|
||||
fontSize: "12px",
|
||||
fontWeight: activeTabId === tab.id ? 700 : 500,
|
||||
color:
|
||||
activeTabId === tab.id
|
||||
? "hsl(var(--primary))"
|
||||
: "hsl(var(--muted-foreground))",
|
||||
background: activeTabId === tab.id ? "hsl(var(--card))" : "transparent",
|
||||
border: "none",
|
||||
borderBottom:
|
||||
activeTabId === tab.id
|
||||
? "2px solid hsl(var(--primary))"
|
||||
: "2px solid transparent",
|
||||
cursor: "pointer",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
{tab.icon && <span aria-hidden>{tab.icon}</span>}
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ flex: 1, padding, minHeight: 0, overflow: "auto" }}>
|
||||
<div style={{ color: "hsl(var(--muted-foreground))", fontSize: "11px", textAlign: "center", padding: "20px" }}>
|
||||
[{activeTab}] 탭 컨텐츠 영역
|
||||
</div>
|
||||
{/*
|
||||
탭 body — 디자인 모드 빌더에서 drop / 선택을 받는 영역.
|
||||
data 속성 3개로 ScreenDesigner.handleComponentDrop 의 기존 tabs 분기가 그대로
|
||||
canonical container 도 인식하게 한다 (별도 코드 패스 추가 없음).
|
||||
*/}
|
||||
<div
|
||||
data-tabs-container="true"
|
||||
data-container-kind="canonical"
|
||||
data-component-id={(component as any)?.id ?? ""}
|
||||
data-active-tab-id={activeTabId}
|
||||
style={{ flex: 1, padding, minHeight: 0, overflow: "auto" }}
|
||||
onClick={(e) => {
|
||||
// 빈 탭 안내 영역까지 포함해 body 클릭 시 탭 자식 선택 해제.
|
||||
// ChildSlot click 은 stopPropagation 하므로 자식 선택과 충돌하지 않는다.
|
||||
if (!isDesignMode) return;
|
||||
if (typeof p.onSelectTabComponent === "function") {
|
||||
e.stopPropagation();
|
||||
p.onSelectTabComponent(activeTabId, "", null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{renderTabChildren(currentTab)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 활성 탭 한 개의 자식 컴포넌트만 렌더. 비활성 탭은 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 (
|
||||
<div
|
||||
style={{
|
||||
border: "1px dashed hsl(var(--border))",
|
||||
borderRadius: 4,
|
||||
padding: "24px",
|
||||
textAlign: "center",
|
||||
color: "hsl(var(--muted-foreground))",
|
||||
fontSize: "11px",
|
||||
minHeight: 80,
|
||||
}}
|
||||
>
|
||||
이 탭이 비어 있습니다. 좌측 팔레트에서 컴포넌트를 끌어다 놓으세요.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 8,
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
{childList.map((child) => {
|
||||
const isChildSelected = !!selectedChildId && child.id === selectedChildId;
|
||||
return (
|
||||
<ChildSlot
|
||||
key={child.id}
|
||||
child={child}
|
||||
isDesignMode={isDesignMode}
|
||||
isSelected={isChildSelected}
|
||||
containerId={containerId}
|
||||
tabId={tabId}
|
||||
onSelect={
|
||||
onSelectChild
|
||||
? (e) => {
|
||||
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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── section ───────────────────────────────────────────────────────────
|
||||
const [collapsed, setCollapsed] = useState(componentConfig.defaultCollapsed ?? false);
|
||||
|
||||
@@ -290,6 +465,120 @@ export const ContainerComponent: React.FC<ContainerComponentProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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<string, any>;
|
||||
}> = ({ 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 (
|
||||
<div
|
||||
style={wrapStyle}
|
||||
data-tab-child-id={child.id}
|
||||
data-parent-tab-id={tabId}
|
||||
data-parent-tabs-id={containerId}
|
||||
onClick={isDesignMode && onSelect ? onSelect : undefined}
|
||||
onMouseEnter={isDesignMode ? () => setHover(true) : undefined}
|
||||
onMouseLeave={isDesignMode ? () => setHover(false) : undefined}
|
||||
>
|
||||
{isDesignMode && (isSelected || hover) && onRemove && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
title="이 탭 자식 삭제"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 4,
|
||||
right: 4,
|
||||
zIndex: 5,
|
||||
width: 22,
|
||||
height: 22,
|
||||
padding: 0,
|
||||
border: "1px solid hsl(var(--border))",
|
||||
borderRadius: 4,
|
||||
background: "hsl(var(--card))",
|
||||
color: "hsl(var(--destructive))",
|
||||
cursor: "pointer",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: 12,
|
||||
lineHeight: 1,
|
||||
boxShadow: "0 1px 2px rgba(0,0,0,0.1)",
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
{/*
|
||||
디자인 모드에서는 자식 컴포넌트의 자체 클릭 핸들러가 ChildSlot 의 선택 click 을
|
||||
가로채지 않도록 pointer-events 차단. 운영 모드에서는 자식이 정상 동작.
|
||||
*/}
|
||||
<div
|
||||
style={{
|
||||
pointerEvents: isDesignMode ? "none" : undefined,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
<DynamicComponentRenderer
|
||||
component={componentForRenderer as any}
|
||||
isDesignMode={isDesignMode}
|
||||
{...passProps}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ContainerWrapper: React.FC<ContainerComponentProps> = (props) => {
|
||||
return <ContainerComponent {...props} />;
|
||||
};
|
||||
|
||||
@@ -194,6 +194,20 @@ export const InvContainerConfigPanel: React.FC<InvContainerConfigPanelProps> = (
|
||||
}}
|
||||
placeholder="탭 라벨"
|
||||
/>
|
||||
<span
|
||||
title="이 탭에 배치된 컴포넌트 수"
|
||||
style={{
|
||||
fontSize: 9.5,
|
||||
color: "var(--cp-text-muted)",
|
||||
fontFamily: "var(--v5-font-mono)",
|
||||
minWidth: 28,
|
||||
textAlign: "right",
|
||||
}}
|
||||
>
|
||||
{(tab.components?.length ?? 0) > 0
|
||||
? `${tab.components!.length}개`
|
||||
: "·"}
|
||||
</span>
|
||||
<CPIconBtn
|
||||
tone="danger"
|
||||
size={20}
|
||||
|
||||
@@ -29,9 +29,45 @@ export type ContainerType =
|
||||
|
||||
export type SectionVariant = "card" | "paper" | "plain";
|
||||
|
||||
/**
|
||||
* 탭 / 컨테이너 내부에 배치되는 단일 자식 컴포넌트 디스크립터.
|
||||
*
|
||||
* Phase G.2 (Tabbed Data View) 에서 탭마다 다른 child 를 렌더하기 위한
|
||||
* 최소 shape. `DynamicComponentRenderer` 가 받는 `componentType / componentConfig`
|
||||
* 형식과 1:1 매칭. 빌더가 캔버스에서 컴포넌트를 추가하면 이 모양으로 저장된다.
|
||||
*
|
||||
* 의도적으로 `TemplateComponent` 전체보다 작게 유지 — order / row / responsive /
|
||||
* parentId 등은 빌더의 자유 배치 layout 영역이며 탭 child 렌더에는 불필요.
|
||||
*/
|
||||
export interface ContainerChildComponent {
|
||||
id: string;
|
||||
/** ComponentRegistry ID — 예: "table", "stats", "search", "button", "input" */
|
||||
componentType: string;
|
||||
/** 컴포넌트별 설정. 로우코드 원칙상 느슨한 Record */
|
||||
componentConfig?: Record<string, any>;
|
||||
/** 빌더에서 표시되는 라벨 (선택) */
|
||||
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 {
|
||||
|
||||
@@ -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<GroupedTableComponentProps> = ({
|
||||
component,
|
||||
isDesignMode = false,
|
||||
isSelected = false,
|
||||
onClick,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
config,
|
||||
className,
|
||||
style,
|
||||
...props
|
||||
}) => {
|
||||
const p = props as any;
|
||||
const fromProps: Partial<GroupedTableConfig> = {};
|
||||
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<string, unknown>) || 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<string, Record<string, any>[]>();
|
||||
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 (
|
||||
<div
|
||||
style={containerStyle}
|
||||
className={className}
|
||||
onClick={handleClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
{...filterDOMProps(domProps)}
|
||||
>
|
||||
{(title || isPreview || loading) && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
color: "hsl(var(--foreground))",
|
||||
marginBottom: 6,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<span>{title || (isPreview ? "그룹 테이블 (미리보기)" : "")}</span>
|
||||
{loading && (
|
||||
<span style={{ fontSize: 10, color: "hsl(var(--muted-foreground))", fontWeight: 500 }}>
|
||||
로딩…
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: "auto" }}>
|
||||
{error ? (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minHeight: 80,
|
||||
fontSize: 12,
|
||||
color: "hsl(var(--muted-foreground))",
|
||||
}}
|
||||
>
|
||||
데이터 로드 실패: {error}
|
||||
</div>
|
||||
) : rows.length === 0 || effectiveColumns.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minHeight: 80,
|
||||
fontSize: 12,
|
||||
color: "hsl(var(--muted-foreground))",
|
||||
}}
|
||||
>
|
||||
{emptyText}
|
||||
</div>
|
||||
) : (
|
||||
<table
|
||||
style={{
|
||||
width: "100%",
|
||||
borderCollapse: "collapse",
|
||||
fontSize: 11.5,
|
||||
fontFamily: "var(--v5-font-sans)",
|
||||
}}
|
||||
>
|
||||
<thead>
|
||||
<tr style={{ background: "hsl(var(--muted))" }}>
|
||||
{effectiveColumns.map((c, i) => (
|
||||
<th
|
||||
key={i}
|
||||
style={{
|
||||
padding: "6px 8px",
|
||||
fontSize: 10.5,
|
||||
fontWeight: 700,
|
||||
textAlign: c.align ?? "left",
|
||||
color: "hsl(var(--muted-foreground))",
|
||||
borderBottom: "1px solid hsl(var(--border))",
|
||||
width: c.width,
|
||||
}}
|
||||
>
|
||||
{c.label ?? c.column}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{grouped.map((g, gi) => (
|
||||
<React.Fragment key={gi}>
|
||||
{groupBy && (
|
||||
<tr style={{ background: "hsl(var(--accent))" }}>
|
||||
<td
|
||||
colSpan={effectiveColumns.length}
|
||||
style={{
|
||||
padding: "5px 8px",
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
color: "var(--v5-primary, hsl(var(--primary)))",
|
||||
borderBottom: "1px solid hsl(var(--border))",
|
||||
}}
|
||||
>
|
||||
{g.key}
|
||||
<span
|
||||
style={{
|
||||
marginLeft: 8,
|
||||
fontSize: 10,
|
||||
fontWeight: 500,
|
||||
color: "hsl(var(--muted-foreground))",
|
||||
}}
|
||||
>
|
||||
({g.values.length}건)
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{g.values.map((row, ri) => (
|
||||
<tr
|
||||
key={ri}
|
||||
style={{
|
||||
borderBottom: "1px solid hsl(var(--border))",
|
||||
}}
|
||||
>
|
||||
{effectiveColumns.map((c, ci) => (
|
||||
<td
|
||||
key={ci}
|
||||
style={{
|
||||
padding: "5px 8px",
|
||||
textAlign: c.align ?? "left",
|
||||
color: "hsl(var(--foreground))",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{cellToText(row[c.column])}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const GroupedTableWrapper: React.FC<GroupedTableComponentProps> = (props) => {
|
||||
return <GroupedTableComponent {...props} />;
|
||||
};
|
||||
@@ -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 <GroupedTableComponent {...this.props} renderer={this} />;
|
||||
}
|
||||
}
|
||||
|
||||
GroupedTableRenderer.registerSelf();
|
||||
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
GroupedTableRenderer.enableHotReload();
|
||||
}
|
||||
@@ -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<InvGroupedTableConfigPanelProps> = ({
|
||||
config,
|
||||
onChange,
|
||||
selectedComponent,
|
||||
}) => {
|
||||
const current: GroupedTableConfig =
|
||||
(config as GroupedTableConfig) || (selectedComponent?.config as GroupedTableConfig) || {};
|
||||
const patch = (p: Partial<GroupedTableConfig>) => onChange?.({ ...current, ...p });
|
||||
|
||||
const ds = current.dataSource ?? {};
|
||||
const patchDataSource = (p: Partial<GroupedTableDataSource>) => {
|
||||
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<OptionFilter>) => {
|
||||
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<GroupedTableColumn>) => {
|
||||
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 (
|
||||
<div style={{ fontFamily: "var(--v5-font-sans)", color: "var(--cp-text)", padding: "0 12px" }}>
|
||||
{/* ── ① 기본 ─────────────────────────── */}
|
||||
<CPSection title="① 기본">
|
||||
<CPRow label="제목">
|
||||
<CPText
|
||||
value={current.title || ""}
|
||||
onChange={(v) => patch({ title: v || undefined })}
|
||||
placeholder="날짜별 근태"
|
||||
/>
|
||||
</CPRow>
|
||||
<CPRow label="빈 결과 문구">
|
||||
<CPText
|
||||
value={current.emptyText || ""}
|
||||
onChange={(v) => patch({ emptyText: v || undefined })}
|
||||
placeholder="데이터 없음"
|
||||
/>
|
||||
</CPRow>
|
||||
</CPSection>
|
||||
|
||||
{/* ── ② 데이터 소스 ─────────────────────────── */}
|
||||
<CPSection title="② 데이터 소스">
|
||||
<CPRow label="테이블">
|
||||
<CPSelect
|
||||
value={ds.tableName || ""}
|
||||
onChange={(v) => patchDataSource({ tableName: v || undefined })}
|
||||
>
|
||||
<option value="">선택...</option>
|
||||
{tableOptions.map((t) => (
|
||||
<option key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
</CPSelect>
|
||||
</CPRow>
|
||||
<CPRow label="정렬 컬럼" help="비우면 created_date desc 또는 PK 순">
|
||||
<CPText
|
||||
value={orderByCol}
|
||||
onChange={(v) =>
|
||||
patchDataSource({
|
||||
orderBy: v
|
||||
? [{ column: v, direction: orderByDir as "asc" | "desc" }]
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
placeholder="attendance_date"
|
||||
/>
|
||||
</CPRow>
|
||||
{orderByCol && (
|
||||
<CPRow label="정렬 방향">
|
||||
<CPSegment
|
||||
value={orderByDir}
|
||||
onChange={(v) =>
|
||||
patchDataSource({
|
||||
orderBy: [{ column: orderByCol, direction: v as "asc" | "desc" }],
|
||||
})
|
||||
}
|
||||
options={[
|
||||
{ value: "desc", label: "내림차순" },
|
||||
{ value: "asc", label: "오름차순" },
|
||||
]}
|
||||
/>
|
||||
</CPRow>
|
||||
)}
|
||||
<CPRow label="최대 행 수" help="1 ~ 500. 기본 100">
|
||||
<CPNumber
|
||||
value={ds.limit ?? 100}
|
||||
onChange={(v) => patchDataSource({ limit: v ?? 100 })}
|
||||
min={1}
|
||||
max={500}
|
||||
/>
|
||||
</CPRow>
|
||||
</CPSection>
|
||||
|
||||
{/* ── ③ 그룹 ─────────────────────────── */}
|
||||
<CPSection title="③ 그룹">
|
||||
<CPRow label="그룹 컬럼" help="이 컬럼 값별로 row 묶음">
|
||||
<CPText
|
||||
value={current.groupBy || ""}
|
||||
onChange={(v) => patch({ groupBy: v || undefined })}
|
||||
placeholder="attendance_date, dept_code, ..."
|
||||
/>
|
||||
</CPRow>
|
||||
<CPRow label="그룹 정렬">
|
||||
<CPSegment
|
||||
value={current.sortGroups ?? "none"}
|
||||
onChange={(v) => patch({ sortGroups: v as "none" | "asc" | "desc" })}
|
||||
options={[
|
||||
{ value: "none", label: "원본 순" },
|
||||
{ value: "asc", label: "오름차순" },
|
||||
{ value: "desc", label: "내림차순" },
|
||||
]}
|
||||
/>
|
||||
</CPRow>
|
||||
</CPSection>
|
||||
|
||||
{/* ── ④ 컬럼 ─────────────────────────── */}
|
||||
<CPSection title={`④ 컬럼 ${columns.length > 0 ? `(${columns.length})` : "(자동)"}`}>
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", marginBottom: 5 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addColumn}
|
||||
style={{
|
||||
padding: "3px 8px",
|
||||
fontSize: 10.5,
|
||||
background: "var(--cp-bg-subtle)",
|
||||
border: "1px solid var(--cp-border)",
|
||||
borderRadius: 4,
|
||||
cursor: "pointer",
|
||||
color: "var(--cp-text)",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 3,
|
||||
}}
|
||||
>
|
||||
<Plus size={10} /> 추가
|
||||
</button>
|
||||
</div>
|
||||
{columns.length === 0 ? (
|
||||
<Hint>컬럼 미지정 — row 의 모든 컬럼 자동 표시</Hint>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
{columns.map((c, idx) => (
|
||||
<ColumnRow
|
||||
key={idx}
|
||||
column={c}
|
||||
onChange={(p) => updateColumn(idx, p)}
|
||||
onRemove={() => removeColumn(idx)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CPSection>
|
||||
|
||||
{/* ── ⑤ 필터 ─────────────────────────── */}
|
||||
<CPSection title={`⑤ 필터 ${filters.length > 0 ? `(${filters.length})` : ""}`}>
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", marginBottom: 5 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addFilter}
|
||||
style={{
|
||||
padding: "3px 8px",
|
||||
fontSize: 10.5,
|
||||
background: "var(--cp-bg-subtle)",
|
||||
border: "1px solid var(--cp-border)",
|
||||
borderRadius: 4,
|
||||
cursor: "pointer",
|
||||
color: "var(--cp-text)",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 3,
|
||||
}}
|
||||
>
|
||||
<Plus size={10} /> 추가
|
||||
</button>
|
||||
</div>
|
||||
{filters.length === 0 ? (
|
||||
<Hint>필터 없음 — 테이블 전체 row</Hint>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
{filters.map((f, idx) => (
|
||||
<OptionFilterRow
|
||||
key={idx}
|
||||
filter={f}
|
||||
onChange={(p) => updateFilter(idx, p)}
|
||||
onRemove={() => removeFilter(idx)}
|
||||
tableName={ds.tableName}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CPSection>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
InvGroupedTableConfigPanel.displayName = "InvGroupedTableConfigPanel";
|
||||
|
||||
function ColumnRow({
|
||||
column,
|
||||
onChange,
|
||||
onRemove,
|
||||
}: {
|
||||
column: GroupedTableColumn;
|
||||
onChange: (p: Partial<GroupedTableColumn>) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1.2fr 1fr 60px 70px 18px",
|
||||
gap: 4,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={column.column}
|
||||
onChange={(e) => onChange({ column: e.target.value })}
|
||||
placeholder="컬럼명"
|
||||
style={inputBase()}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={column.label || ""}
|
||||
onChange={(e) => onChange({ label: e.target.value || undefined })}
|
||||
placeholder="라벨"
|
||||
style={inputBase()}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={column.width || ""}
|
||||
onChange={(e) => onChange({ width: e.target.value || undefined })}
|
||||
placeholder="auto"
|
||||
style={inputBase()}
|
||||
/>
|
||||
<select
|
||||
value={column.align || "left"}
|
||||
onChange={(e) =>
|
||||
onChange({ align: e.target.value as GroupedTableColumn["align"] })
|
||||
}
|
||||
style={{ ...inputBase(), height: 22 }}
|
||||
>
|
||||
<option value="left">왼쪽</option>
|
||||
<option value="center">가운데</option>
|
||||
<option value="right">오른쪽</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
style={{
|
||||
width: 18,
|
||||
height: 18,
|
||||
padding: 0,
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
cursor: "pointer",
|
||||
color: "var(--cp-text-muted)",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
aria-label="컬럼 삭제"
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -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<GroupedTableConfig> = {
|
||||
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<string, any>,
|
||||
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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -642,8 +642,9 @@ export const InputComponent: React.FC<InputComponentProps> = ({
|
||||
<TagPicker
|
||||
value={arrValue}
|
||||
onChange={(v) => 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<InputComponentProps> = ({
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<TogglePicker
|
||||
value={value}
|
||||
onChange={(v) => 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 (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6, padding: "0 8px", height: "100%" }}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={lockEdit}
|
||||
onClick={() => !lockEdit && propagate(true)}
|
||||
style={{
|
||||
...buttonBase,
|
||||
background: checked ? "var(--primary, #3b82f6)" : "transparent",
|
||||
color: checked ? "var(--primary-foreground, #ffffff)" : "inherit",
|
||||
}}
|
||||
>
|
||||
{trueLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={lockEdit}
|
||||
onClick={() => !lockEdit && propagate(false)}
|
||||
style={{
|
||||
...buttonBase,
|
||||
background: !checked ? "var(--primary, #3b82f6)" : "transparent",
|
||||
color: !checked ? "var(--primary-foreground, #ffffff)" : "inherit",
|
||||
}}
|
||||
>
|
||||
{falseLabel}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<label
|
||||
style={{
|
||||
@@ -743,17 +794,17 @@ export const InputComponent: React.FC<InputComponentProps> = ({
|
||||
gap: "6px",
|
||||
fontSize: "13px",
|
||||
padding: "0 8px",
|
||||
cursor: disabled ? "not-allowed" : "pointer",
|
||||
cursor: lockEdit ? "not-allowed" : "pointer",
|
||||
opacity: lockEdit ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!value}
|
||||
checked={checked}
|
||||
onChange={(e) => propagate(e.target.checked)}
|
||||
disabled={disabled}
|
||||
readOnly={readonly}
|
||||
disabled={lockEdit}
|
||||
/>
|
||||
<span>{placeholder || label || "체크"}</span>
|
||||
<span>{checked ? trueLabel : falseLabel}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -541,12 +541,16 @@ export const TagPicker = React.forwardRef<HTMLDivElement, TagPickerProps>(
|
||||
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<HTMLDivElement, TagPickerProps>(
|
||||
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<HTMLDivElement, TagPickerProps>(
|
||||
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) => (
|
||||
<span
|
||||
@@ -596,10 +602,24 @@ export const TagPicker = React.forwardRef<HTMLDivElement, TagPickerProps>(
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => 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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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<InvStatsConfigPanelProps> = ({
|
||||
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<InvStatsConfigPanelProps> = ({
|
||||
};
|
||||
|
||||
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<InvStatsConfigPanelProps> = ({
|
||||
|
||||
const orientation = current.orientation || "horizontal";
|
||||
const styleMode = current.style || "card";
|
||||
const sourceTable = current.sourceTable || screenTableName || "";
|
||||
|
||||
return (
|
||||
<div style={{ fontFamily: "var(--v5-font-sans)", color: "var(--cp-text)", padding: "0 12px" }}>
|
||||
@@ -103,28 +189,8 @@ export const InvStatsConfigPanel: React.FC<InvStatsConfigPanelProps> = ({
|
||||
</CPRow>
|
||||
</CPSection>
|
||||
|
||||
{/* ── ② 데이터 소스 ─────────────────────────── */}
|
||||
<CPSection title="② 데이터 소스" desc="집계할 DB 테이블 (선택)">
|
||||
<CPRow label="테이블">
|
||||
<CPSelect
|
||||
value={sourceTable}
|
||||
onChange={(v) => {
|
||||
onTableChange?.(v);
|
||||
patch({ sourceTable: v || undefined });
|
||||
}}
|
||||
>
|
||||
<option value="">선택...</option>
|
||||
{tableOptions.map((t) => (
|
||||
<option key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
</CPSelect>
|
||||
</CPRow>
|
||||
</CPSection>
|
||||
|
||||
{/* ── ③ 배치 + 스타일 ─────────────────────────── */}
|
||||
<CPSection title="③ 배치 + 스타일" desc="항목을 어떻게 보여줄지">
|
||||
{/* ── ② 배치 + 스타일 ─────────────────────────── */}
|
||||
<CPSection title="② 배치 + 스타일" desc="항목을 어떻게 보여줄지">
|
||||
<CPRow label="배치">
|
||||
<CPSegment
|
||||
value={orientation}
|
||||
@@ -178,26 +244,18 @@ export const InvStatsConfigPanel: React.FC<InvStatsConfigPanelProps> = ({
|
||||
</div>
|
||||
</CPSection>
|
||||
|
||||
{/* ── ④ 색상 프리셋 ─────────────────────────── */}
|
||||
<CPSection title="④ 색상 프리셋" desc="모든 항목에 일괄 적용">
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(2, 1fr)",
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
{/* ── ③ 색상 프리셋 ─────────────────────────── */}
|
||||
<CPSection title="③ 색상 프리셋" desc="모든 항목에 일괄 적용">
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(2, 1fr)", gap: 6 }}>
|
||||
{COLOR_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (items.length === 0) {
|
||||
// 항목이 없으면 4개 만들고 색 적용
|
||||
patch({
|
||||
items: preset.colors.map((c, i) => ({
|
||||
label: `항목 ${i + 1}`,
|
||||
value: 0,
|
||||
...blankItem(i + 1),
|
||||
color: c,
|
||||
})),
|
||||
});
|
||||
@@ -223,7 +281,7 @@ export const InvStatsConfigPanel: React.FC<InvStatsConfigPanelProps> = ({
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLButtonElement).style.borderColor =
|
||||
"rgba(var(--v5-primary-rgb), 0.4)";
|
||||
"hsl(var(--primary) / 0.4)";
|
||||
(e.currentTarget as HTMLButtonElement).style.background =
|
||||
"var(--cp-surface-hover, var(--cp-surface))";
|
||||
}}
|
||||
@@ -248,33 +306,20 @@ export const InvStatsConfigPanel: React.FC<InvStatsConfigPanelProps> = ({
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10.5,
|
||||
fontWeight: 600,
|
||||
color: "var(--cp-text-sec)",
|
||||
letterSpacing: "-0.005em",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 10.5, fontWeight: 600, color: "var(--cp-text-sec)", letterSpacing: "-0.005em" }}>
|
||||
{preset.name}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Hint>
|
||||
항목이 없으면 4개 자동 생성 + 색 적용. 있으면 기존 항목 색만 일괄 변경.
|
||||
항목이 없으면 KPI 4개 자동 생성 + 색 적용. 있으면 기존 항목 색만 일괄 변경.
|
||||
</Hint>
|
||||
</CPSection>
|
||||
|
||||
{/* ── ⑤ 항목 list ─────────────────────────── */}
|
||||
<CPSection title="⑤ 항목" desc={`${items.length}개`}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
marginBottom: 5,
|
||||
}}
|
||||
>
|
||||
{/* ── ④ KPI 항목 ─────────────────────────── */}
|
||||
<CPSection title="④ KPI 항목" desc={`${items.length}개`}>
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", marginBottom: 5 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addItem}
|
||||
@@ -290,6 +335,19 @@ export const InvStatsConfigPanel: React.FC<InvStatsConfigPanelProps> = ({
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
transition: "background-color .15s ease, border-color .15s ease, color .15s ease",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
const b = e.currentTarget as HTMLButtonElement;
|
||||
b.style.borderColor = "hsl(var(--primary) / 0.4)";
|
||||
b.style.color = "hsl(var(--primary))";
|
||||
b.style.background = "hsl(var(--primary) / 0.06)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
const b = e.currentTarget as HTMLButtonElement;
|
||||
b.style.borderColor = "var(--cp-border)";
|
||||
b.style.color = "var(--cp-text)";
|
||||
b.style.background = "var(--cp-bg-subtle)";
|
||||
}}
|
||||
>
|
||||
<Plus size={10} /> 추가
|
||||
@@ -298,7 +356,7 @@ export const InvStatsConfigPanel: React.FC<InvStatsConfigPanelProps> = ({
|
||||
|
||||
{items.length === 0 ? (
|
||||
<Hint>
|
||||
항목이 없습니다. [+ 추가] 또는 위 [색상 프리셋] 클릭으로 4개 자동 생성.
|
||||
KPI 항목이 없습니다. [+ 추가] 또는 위 [색상 프리셋] 클릭으로 4개 자동 생성.
|
||||
</Hint>
|
||||
) : (
|
||||
<div
|
||||
@@ -315,6 +373,7 @@ export const InvStatsConfigPanel: React.FC<InvStatsConfigPanelProps> = ({
|
||||
index={idx}
|
||||
item={item}
|
||||
isLast={idx === items.length - 1}
|
||||
tableOptions={tableOptions}
|
||||
onChange={(p) => updateItem(idx, p)}
|
||||
onRemove={() => removeItem(idx)}
|
||||
/>
|
||||
@@ -329,7 +388,7 @@ export const InvStatsConfigPanel: React.FC<InvStatsConfigPanelProps> = ({
|
||||
InvStatsConfigPanel.displayName = "InvStatsConfigPanel";
|
||||
|
||||
// ───────────────────────────────────────────────────────
|
||||
// CardPreview / ChipPreview / BigNumberPreview — 표시 스타일 미리보기
|
||||
// preview cards (시각 스타일 미리보기)
|
||||
// ───────────────────────────────────────────────────────
|
||||
function CardPreview() {
|
||||
return (
|
||||
@@ -359,8 +418,8 @@ function ChipPreview() {
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
padding: "2px 8px",
|
||||
background: "rgba(var(--v5-primary-rgb), 0.15)",
|
||||
color: "var(--v5-primary, #6c5ce7)",
|
||||
background: "hsl(var(--primary) / 0.15)",
|
||||
color: "hsl(var(--primary))",
|
||||
borderRadius: 999,
|
||||
fontSize: 9,
|
||||
fontWeight: 700,
|
||||
@@ -381,23 +440,60 @@ function BigNumberPreview() {
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────
|
||||
// ItemEditRow — 항목 한 줄 (라벨+값 + 펼침: icon/색/델타)
|
||||
// ItemEditRow — Phase G.4.2 DB-first KPI row
|
||||
// ───────────────────────────────────────────────────────
|
||||
function ItemEditRow({
|
||||
index,
|
||||
item,
|
||||
isLast,
|
||||
tableOptions,
|
||||
onChange,
|
||||
onRemove,
|
||||
}: {
|
||||
index: number;
|
||||
item: StatsItem;
|
||||
isLast: boolean;
|
||||
tableOptions: { value: string; label: string }[];
|
||||
onChange: (p: Partial<StatsItem>) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const [hover, setHover] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const expandAnim = useSlideToggle(expanded);
|
||||
|
||||
const ds = item.dataSource ?? { aggregation: "count" };
|
||||
const aggregation: StatsAggregation = ds.aggregation ?? "count";
|
||||
const needsColumn = aggregation !== "count";
|
||||
const filters = ds.filters ?? [];
|
||||
const filterCount = filters.length;
|
||||
|
||||
// 항상 dataSource 가 있도록 보장 (G.4.2 — DB-first). 새 항목은 default 가 이미 채워져
|
||||
// 있지만, 옛 데이터 (value 만 있는 legacy item) 도 inline 편집 가능하게.
|
||||
const patchDs = (p: Partial<StatItemDataSource>) => {
|
||||
const next = { ...ds, ...p };
|
||||
if (!next.tableName && (!next.filters || next.filters.length === 0) && !next.columnName && !next.aggregation) {
|
||||
// 모두 비면 dataSource 제거 (사용자가 명시적으로 비웠다는 의도)
|
||||
onChange({ dataSource: undefined });
|
||||
} else {
|
||||
onChange({ dataSource: next });
|
||||
}
|
||||
};
|
||||
|
||||
const updateFilter = (i: number, f: Partial<OptionFilter>) => {
|
||||
const next = filters.map((it, k) => (k === i ? { ...it, ...f } : it));
|
||||
patchDs({ filters: next });
|
||||
};
|
||||
const addFilter = () =>
|
||||
patchDs({
|
||||
filters: [
|
||||
...filters,
|
||||
{ column: "", operator: "=", value_type: "static", value: "" } as OptionFilter,
|
||||
],
|
||||
});
|
||||
const removeFilter = (i: number) => {
|
||||
const next = filters.filter((_, k) => k !== i);
|
||||
patchDs({ filters: next.length === 0 ? undefined : next });
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -405,66 +501,194 @@ function ItemEditRow({
|
||||
onMouseLeave={() => setHover(false)}
|
||||
style={{
|
||||
borderBottom: isLast ? "none" : "1px solid var(--cp-border-subtle)",
|
||||
background: hover ? "var(--cp-surface-hover, var(--cp-surface))" : "transparent",
|
||||
transition: "background .12s ease",
|
||||
// expanded 강조는 close animation 동안 유지하기 위해 render 기준 (slide-up
|
||||
// 끝나야 비활성). 배경은 옅은 hover tint 만.
|
||||
background:
|
||||
hover && !expandAnim.render
|
||||
? "hsl(var(--primary) / 0.025)"
|
||||
: "transparent",
|
||||
borderLeft: `2px solid ${
|
||||
expandAnim.render ? "hsl(var(--primary))" : "transparent"
|
||||
}`,
|
||||
transition:
|
||||
"background-color .15s ease, border-left-color .15s ease",
|
||||
fontFamily: "var(--v5-font-sans)",
|
||||
animation: "stats-kpi-fade-in .18s ease-out",
|
||||
}}
|
||||
>
|
||||
{/* 한 줄: # · color dot · 라벨 · 값 · 펼침 · 삭제 */}
|
||||
{/* collapsed KPI — 단일 padded 컨테이너 + flex column gap. 일관된 간격. */}
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "16px 10px 1fr 80px 18px 22px",
|
||||
alignItems: "center",
|
||||
columnGap: 6,
|
||||
padding: "5px 8px",
|
||||
minHeight: 28,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<RowNumberBadge n={index + 1} />
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 2,
|
||||
background: item.color || "var(--cp-border-strong, var(--cp-border))",
|
||||
boxShadow: item.color ? `0 0 4px ${item.color}55` : "none",
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={item.label}
|
||||
onChange={(e) => onChange({ label: e.target.value })}
|
||||
placeholder="라벨"
|
||||
style={inputStyle()}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={item.value?.toString() ?? ""}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
onChange({ value: isNaN(Number(v)) || v === "" ? v : Number(v) });
|
||||
}}
|
||||
placeholder="값"
|
||||
style={inputStyle({ mono: true })}
|
||||
/>
|
||||
<RowExpandChevron
|
||||
expanded={expanded}
|
||||
onToggle={() => setExpanded((x) => !x)}
|
||||
/>
|
||||
<RowDeleteBtn onClick={onRemove} visible={hover} />
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
{/* 1줄 — 라벨 input + 삭제 (펼침 chevron 제거; 메타 chip 클릭으로 토글) */}
|
||||
<div
|
||||
style={{
|
||||
padding: "4px 8px 8px 30px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 6,
|
||||
display: "grid",
|
||||
gridTemplateColumns: "16px minmax(0, 1fr) 22px",
|
||||
alignItems: "center",
|
||||
columnGap: 6,
|
||||
}}
|
||||
>
|
||||
<RowNumberBadge n={index + 1} />
|
||||
<input
|
||||
type="text"
|
||||
value={item.label}
|
||||
onChange={(e) => onChange({ label: e.target.value })}
|
||||
placeholder="라벨"
|
||||
style={inputStyle()}
|
||||
title="KPI 라벨"
|
||||
/>
|
||||
<RowDeleteBtn onClick={onRemove} visible={hover} />
|
||||
</div>
|
||||
{/* 2줄 — 메타 chip 클릭 = expand 토글. 라벨 column 과 동일 grid 로 정렬 */}
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "16px minmax(0, 1fr) 22px",
|
||||
alignItems: "center",
|
||||
columnGap: 6,
|
||||
}}
|
||||
>
|
||||
<span />
|
||||
<KpiMetaSegment
|
||||
tableValue={ds.tableName}
|
||||
tableLabel={
|
||||
ds.tableName
|
||||
? tableOptions.find((t) => t.value === ds.tableName)?.label ?? ds.tableName
|
||||
: undefined
|
||||
}
|
||||
aggregationLabel={
|
||||
AGGREGATION_OPTS.find((o) => o.value === aggregation)?.label ?? aggregation
|
||||
}
|
||||
columnName={ds.columnName}
|
||||
filterCount={filterCount}
|
||||
expanded={expandAnim.render}
|
||||
onClick={() => setExpanded((x) => !x)}
|
||||
/>
|
||||
<span />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expandAnim.render && (
|
||||
<div
|
||||
className="stats-kpi-expand"
|
||||
style={{
|
||||
padding: "4px 10px 8px 26px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 4,
|
||||
overflow: "hidden",
|
||||
animation: expandAnim.closing
|
||||
? "stats-kpi-slide-up .18s ease-out forwards"
|
||||
: "stats-kpi-slide-down .18s ease-out",
|
||||
}}
|
||||
>
|
||||
{/* ── 데이터 ───────────────────────────── */}
|
||||
<SectionLabel text="데이터" />
|
||||
<CPRow label="테이블">
|
||||
<CPSelect
|
||||
value={ds.tableName || ""}
|
||||
onChange={(v) => patchDs({ tableName: v || undefined })}
|
||||
>
|
||||
<option value="">선택...</option>
|
||||
{tableOptions.map((t) => (
|
||||
<option key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
</CPSelect>
|
||||
</CPRow>
|
||||
<CPRow label="집계">
|
||||
<CPSelect
|
||||
value={aggregation}
|
||||
onChange={(v) => patchDs({ aggregation: (v as StatsAggregation) || "count" })}
|
||||
>
|
||||
{AGGREGATION_OPTS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</CPSelect>
|
||||
</CPRow>
|
||||
{(needsColumn || ds.columnName) && (
|
||||
<CPRow label="컬럼" help={needsColumn ? "필수" : "(count 는 비워도 됨)"}>
|
||||
<ColumnPicker
|
||||
tableName={ds.tableName}
|
||||
value={ds.columnName}
|
||||
onChange={(v) => patchDs({ columnName: v || undefined })}
|
||||
/>
|
||||
</CPRow>
|
||||
)}
|
||||
{!ds.tableName && (
|
||||
<Hint>
|
||||
테이블을 선택하면 운영 모드에서 DB 집계 값이 자동 표시됩니다. 디자인 모드는 항상 "—".
|
||||
</Hint>
|
||||
)}
|
||||
|
||||
{/* ── 필터 ───────────────────────────── */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginTop: 8,
|
||||
}}
|
||||
>
|
||||
<SectionLabel text={`필터${filterCount > 0 ? ` (${filterCount})` : ""}`} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={addFilter}
|
||||
style={{
|
||||
padding: "2px 6px",
|
||||
fontSize: 10,
|
||||
background: "var(--cp-surface)",
|
||||
border: "1px solid var(--cp-border)",
|
||||
borderRadius: 3,
|
||||
cursor: "pointer",
|
||||
color: "var(--cp-text)",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 3,
|
||||
transition:
|
||||
"background-color .15s ease, border-color .15s ease, color .15s ease",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
const b = e.currentTarget as HTMLButtonElement;
|
||||
b.style.borderColor = "hsl(var(--primary) / 0.4)";
|
||||
b.style.color = "hsl(var(--primary))";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
const b = e.currentTarget as HTMLButtonElement;
|
||||
b.style.borderColor = "var(--cp-border)";
|
||||
b.style.color = "var(--cp-text)";
|
||||
}}
|
||||
>
|
||||
<Plus size={9} /> 추가
|
||||
</button>
|
||||
</div>
|
||||
{filterCount === 0 ? (
|
||||
<Hint>필터 없음 — 테이블 전체 집계</Hint>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
{filters.map((f, i) => (
|
||||
<OptionFilterRow
|
||||
key={i}
|
||||
filter={f}
|
||||
onChange={(p) => updateFilter(i, p)}
|
||||
onRemove={() => removeFilter(i)}
|
||||
tableName={ds.tableName}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 외형 ───────────────────────────── */}
|
||||
<SectionLabel text="외형" />
|
||||
<CPRow label="아이콘">
|
||||
<IconPicker
|
||||
value={item.icon || ""}
|
||||
@@ -477,59 +701,216 @@ function ItemEditRow({
|
||||
onChange={(v) => onChange({ color: v || undefined })}
|
||||
/>
|
||||
</CPRow>
|
||||
<CPRow label="변화량" help="예: +12.4% / -3 / 0">
|
||||
|
||||
{/* ── 고급 ───────────────────────────── */}
|
||||
<AdvancedSection item={item} onChange={onChange} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────
|
||||
// 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: (
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 3 }}>
|
||||
<FilterIcon size={9} />
|
||||
{filterCount}
|
||||
</span>
|
||||
),
|
||||
color:
|
||||
filterCount > 0 ? "hsl(var(--primary))" : "var(--cp-text-muted)",
|
||||
weight: filterCount > 0 ? 700 : 500,
|
||||
});
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
title={`${tableLabel}${
|
||||
tableValue && tableValue !== tableLabel ? ` (${tableValue})` : ""
|
||||
} · ${aggregationLabel}${columnName ? ` · ${columnName}` : ""} · 필터 ${filterCount}`}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "stretch",
|
||||
minHeight: 22,
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
cursor: "pointer",
|
||||
fontFamily: "var(--v5-font-mono)",
|
||||
fontSize: 10,
|
||||
padding: 0,
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
{cells.map((cell, i) => (
|
||||
<span
|
||||
key={cell.key}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
padding: "0 10px",
|
||||
color: cell.color,
|
||||
fontWeight: cell.weight,
|
||||
fontStyle: cell.italic ? "italic" : "normal",
|
||||
borderLeft:
|
||||
i === 0 ? "none" : "1px solid var(--cp-border-subtle)",
|
||||
wordBreak: "break-all",
|
||||
minWidth: 0,
|
||||
transition: "color .15s ease",
|
||||
opacity: expanded ? 0.85 : 1,
|
||||
}}
|
||||
>
|
||||
{cell.content}
|
||||
</span>
|
||||
))}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────
|
||||
// AdvancedSection — fallback value + delta (collapsible, default closed)
|
||||
// ───────────────────────────────────────────────────────
|
||||
function AdvancedSection({
|
||||
item,
|
||||
onChange,
|
||||
}: {
|
||||
item: StatsItem;
|
||||
onChange: (p: Partial<StatsItem>) => void;
|
||||
}) {
|
||||
const hasFallback = item.value !== undefined && item.value !== "";
|
||||
const [open, setOpen] = useState(hasFallback);
|
||||
const advAnim = useSlideToggle(open);
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((x) => !x)}
|
||||
style={{
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "4px 0",
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
cursor: "pointer",
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "-0.005em",
|
||||
color: "var(--cp-text-sec)",
|
||||
fontFamily: "var(--v5-font-sans)",
|
||||
transition: "color .15s ease",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLButtonElement).style.color =
|
||||
"var(--cp-text)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLButtonElement).style.color =
|
||||
"var(--cp-text-sec)";
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
고급
|
||||
{hasFallback && (
|
||||
<span style={{ marginLeft: 6, fontSize: 9, fontWeight: 600, color: "var(--cp-text-muted)" }}>
|
||||
· fallback
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 18,
|
||||
height: 18,
|
||||
color: "var(--cp-text-sec)",
|
||||
transition: "transform .18s ease-out, color .15s ease",
|
||||
// close animation 끝날 때까지 회전 유지 (render 기준)
|
||||
transform: advAnim.render ? "rotate(90deg)" : "rotate(0deg)",
|
||||
}}
|
||||
>
|
||||
<ChevronRight size={14} strokeWidth={2.2} />
|
||||
</span>
|
||||
</button>
|
||||
{advAnim.render && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 6,
|
||||
marginTop: 4,
|
||||
overflow: "hidden",
|
||||
animation: advAnim.closing
|
||||
? "stats-kpi-slide-up .18s ease-out forwards"
|
||||
: "stats-kpi-slide-down .18s ease-out",
|
||||
}}
|
||||
>
|
||||
<CPRow label="Fallback 값" help="디자인 모드 / API 실패 시 보여줄 임시 값. 비우면 —">
|
||||
<CPText
|
||||
value={item.delta || ""}
|
||||
onChange={(v) => onChange({ delta: v || undefined })}
|
||||
placeholder="+12%"
|
||||
/>
|
||||
</CPRow>
|
||||
<CPRow label="변화 방향">
|
||||
<CPSegment
|
||||
value={item.deltaDirection || "neutral"}
|
||||
value={item.value?.toString() ?? ""}
|
||||
onChange={(v) =>
|
||||
onChange({ deltaDirection: v as StatsItem["deltaDirection"] })
|
||||
onChange({ value: v === "" ? undefined : isNaN(Number(v)) ? v : Number(v) })
|
||||
}
|
||||
options={[
|
||||
{
|
||||
value: "up",
|
||||
label: (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 2,
|
||||
color: "#10b981",
|
||||
}}
|
||||
>
|
||||
<TrendingUp size={11} /> 상승
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "neutral",
|
||||
label: (
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 2 }}>
|
||||
<Minus size={11} /> 보통
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "down",
|
||||
label: (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 2,
|
||||
color: "#ef4444",
|
||||
}}
|
||||
>
|
||||
<TrendingDown size={11} /> 하락
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]}
|
||||
placeholder="비우면 —"
|
||||
/>
|
||||
</CPRow>
|
||||
</div>
|
||||
@@ -538,6 +919,9 @@ function ItemEditRow({
|
||||
);
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────
|
||||
// inline input/select styles (compact, collapsed row 용)
|
||||
// ───────────────────────────────────────────────────────
|
||||
function inputStyle({ mono = false }: { mono?: boolean } = {}): React.CSSProperties {
|
||||
return {
|
||||
height: 22,
|
||||
|
||||
@@ -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<StatsComponentProps> = ({
|
||||
...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<string, unknown>) || 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<StatsComponentProps> = ({
|
||||
} = 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<string, React.ElementType | undefined>)[
|
||||
iconName
|
||||
];
|
||||
if (Icon) {
|
||||
return React.createElement(Icon, {
|
||||
size,
|
||||
strokeWidth: 2,
|
||||
style: { color, flexShrink: 0 },
|
||||
"aria-hidden": true,
|
||||
});
|
||||
}
|
||||
return (
|
||||
<span style={{ lineHeight: 1, color, flexShrink: 0 }} aria-hidden="true">
|
||||
{iconName}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
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<StatsComponentProps> = ({
|
||||
fontSize: "12px",
|
||||
}}
|
||||
>
|
||||
{item.icon && <span>{item.icon}</span>}
|
||||
{renderIcon(item.icon, 13, color)}
|
||||
<span style={{ color: "hsl(var(--muted-foreground))" }}>{item.label}</span>
|
||||
<strong style={{ color }}>{displayValue}</strong>
|
||||
</div>
|
||||
@@ -237,7 +322,7 @@ export const StatsComponent: React.FC<StatsComponentProps> = ({
|
||||
letterSpacing: "0.05em",
|
||||
}}
|
||||
>
|
||||
{item.icon && `${item.icon} `}
|
||||
{renderIcon(item.icon, 12, "hsl(var(--muted-foreground))")}
|
||||
{item.label}
|
||||
</span>
|
||||
<span
|
||||
@@ -250,6 +335,24 @@ export const StatsComponent: React.FC<StatsComponentProps> = ({
|
||||
>
|
||||
{displayValue}
|
||||
</span>
|
||||
{stateNote && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: "10px",
|
||||
color:
|
||||
stateNote.tone === "error"
|
||||
? "hsl(var(--destructive))"
|
||||
: "hsl(var(--muted-foreground))",
|
||||
maxWidth: "100%",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
title={stateNote.text}
|
||||
>
|
||||
{stateNote.text}
|
||||
</span>
|
||||
)}
|
||||
{item.delta && (
|
||||
<span
|
||||
style={{
|
||||
@@ -294,7 +397,7 @@ export const StatsComponent: React.FC<StatsComponentProps> = ({
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{item.icon && `${item.icon} `}
|
||||
{renderIcon(item.icon, 12, "hsl(var(--muted-foreground))")}
|
||||
{item.label}
|
||||
</span>
|
||||
<span
|
||||
@@ -307,6 +410,24 @@ export const StatsComponent: React.FC<StatsComponentProps> = ({
|
||||
>
|
||||
{displayValue}
|
||||
</span>
|
||||
{stateNote && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: "10px",
|
||||
color:
|
||||
stateNote.tone === "error"
|
||||
? "hsl(var(--destructive))"
|
||||
: "hsl(var(--muted-foreground))",
|
||||
maxWidth: "100%",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
title={stateNote.text}
|
||||
>
|
||||
{stateNote.text}
|
||||
</span>
|
||||
)}
|
||||
{item.delta && (
|
||||
<span
|
||||
style={{
|
||||
|
||||
@@ -20,7 +20,8 @@ import type { StatsConfig } from "./types";
|
||||
* 변형:
|
||||
* - style: card | chip | bigNumber
|
||||
* - orientation: horizontal | vertical | grid
|
||||
* - items: 각 KPI 항목 (라벨, 값, 아이콘, 색상, 변화량)
|
||||
|
||||
* - items: 각 KPI 항목 (라벨, DB 바인딩, 아이콘, 색상, 선택 fallback / 변화량)
|
||||
*
|
||||
* 관련 문서:
|
||||
* notes/gbpark/2026-04-11-component-unification-plan.md §3.6
|
||||
@@ -29,10 +30,13 @@ import type { StatsConfig } from "./types";
|
||||
const DEFAULT_CONFIG: Partial<StatsConfig> = {
|
||||
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" } },
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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<string, StatsValueState>;
|
||||
|
||||
type StatsFetchPlanItem =
|
||||
| { key: string; skip: true; pending?: boolean }
|
||||
| {
|
||||
key: string;
|
||||
skip: false;
|
||||
reqKey: string;
|
||||
req: {
|
||||
tableName: string;
|
||||
aggregation: NonNullable<StatItemDataSource["aggregation"]>;
|
||||
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<string, unknown> | 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<StatsDataMap>({});
|
||||
|
||||
// 요청 키 직렬화 — 항목 / 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<Map<string, number>>(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;
|
||||
}
|
||||
@@ -116,8 +116,12 @@ const CONFIG_PANEL_MAP: Record<string, () => Promise<any>> = {
|
||||
"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"),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,747 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Invyone — No-Code · Build Anything</title>
|
||||
<link rel="preconnect" href="https://cdn.jsdelivr.net" />
|
||||
<link rel="preconnect" href="https://unpkg.com" />
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css" />
|
||||
<style>
|
||||
:root {
|
||||
--purple: #6c5ce7;
|
||||
--purple-soft: #8b7df0;
|
||||
--purple-glow: rgba(108, 92, 231, 0.6);
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
background: #06061a;
|
||||
font-family: 'Pretendard', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||
color: #fff;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* 스테이지 — 영상 녹화용 고정 크기 (1200 x 1500, 약 4:5) */
|
||||
.stage {
|
||||
position: relative;
|
||||
width: 1200px;
|
||||
height: 1500px;
|
||||
background: radial-gradient(ellipse 80% 60% at 50% 50%, #0e0e35 0%, #08081f 60%, #050514 100%);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* dot 패턴 배경 */
|
||||
.dot-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: radial-gradient(circle, rgba(170, 180, 230, 0.18) 1px, transparent 1.5px);
|
||||
background-size: 26px 26px;
|
||||
opacity: 0.7;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 좌상단 뱃지 */
|
||||
.top-badge {
|
||||
position: absolute;
|
||||
top: 38px;
|
||||
left: 38px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 11px 22px 11px 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.13);
|
||||
border-radius: 999px;
|
||||
background: rgba(15, 15, 40, 0.45);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 2.8px;
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
z-index: 10;
|
||||
}
|
||||
.badge-dot {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 50%;
|
||||
background: var(--purple);
|
||||
box-shadow: 0 0 10px var(--purple-glow), 0 0 4px var(--purple);
|
||||
}
|
||||
|
||||
/* SVG 점선 원 — 절대 중앙 정렬 */
|
||||
.ring-svg {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* 중앙 글로우 원 — 안쪽 펄스 + 코어 */
|
||||
.orb-pulse {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(140, 125, 240, 0.22);
|
||||
pointer-events: none;
|
||||
z-index: 3;
|
||||
}
|
||||
.orb-pulse.p1 { width: 320px; height: 320px; }
|
||||
.orb-pulse.p2 { width: 380px; height: 380px; border-color: rgba(140, 125, 240, 0.14); }
|
||||
.orb-pulse.p3 { width: 440px; height: 440px; border-color: rgba(140, 125, 240, 0.08); }
|
||||
|
||||
.orb {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
border-radius: 50%;
|
||||
background:
|
||||
radial-gradient(circle at 32% 22%, rgba(255, 255, 255, 0.32) 0%, transparent 38%),
|
||||
radial-gradient(circle at 50% 55%, #b8acff 0%, #7567e2 42%, #4936b8 82%, #2d1f8a 100%);
|
||||
box-shadow:
|
||||
inset -16px -16px 38px rgba(38, 26, 105, 0.4),
|
||||
inset 14px 18px 28px rgba(255, 255, 255, 0.06),
|
||||
0 0 55px rgba(108, 92, 231, 0.42),
|
||||
0 0 110px rgba(108, 92, 231, 0.22),
|
||||
0 0 170px rgba(108, 92, 231, 0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 5;
|
||||
animation: orb-breathe 5.5s ease-in-out infinite;
|
||||
}
|
||||
@keyframes orb-breathe {
|
||||
0%, 100% {
|
||||
box-shadow:
|
||||
inset -16px -16px 38px rgba(38, 26, 105, 0.4),
|
||||
inset 14px 18px 28px rgba(255, 255, 255, 0.06),
|
||||
0 0 55px rgba(108, 92, 231, 0.42),
|
||||
0 0 110px rgba(108, 92, 231, 0.22),
|
||||
0 0 170px rgba(108, 92, 231, 0.1);
|
||||
}
|
||||
50% {
|
||||
box-shadow:
|
||||
inset -16px -16px 38px rgba(38, 26, 105, 0.4),
|
||||
inset 14px 18px 28px rgba(255, 255, 255, 0.08),
|
||||
0 0 75px rgba(108, 92, 231, 0.55),
|
||||
0 0 140px rgba(108, 92, 231, 0.3),
|
||||
0 0 210px rgba(108, 92, 231, 0.14);
|
||||
}
|
||||
}
|
||||
|
||||
/* 회전하는 점선 ring (orb과 안쪽 점선 원 사이) */
|
||||
.orb-ring {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
z-index: 4;
|
||||
animation: spin-ring 28s linear infinite;
|
||||
}
|
||||
.orb-ring.reverse {
|
||||
animation: spin-ring-rev 36s linear infinite;
|
||||
}
|
||||
@keyframes spin-ring {
|
||||
to { transform: translate(-50%, -50%) rotate(360deg); }
|
||||
}
|
||||
@keyframes spin-ring-rev {
|
||||
to { transform: translate(-50%, -50%) rotate(-360deg); }
|
||||
}
|
||||
.orb-name {
|
||||
font-size: 56px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
color: #fff;
|
||||
text-shadow: 0 0 12px rgba(255, 255, 255, 0.28);
|
||||
line-height: 1;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
.orb-tag {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.55em;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
text-shadow: 0 0 6px rgba(255, 255, 255, 0.25);
|
||||
margin-top: 16px;
|
||||
padding-top: 14px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
.orb-tag::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 38px;
|
||||
height: 1px;
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
/* 안쪽 4 노드 — 정사각형 점선 박스 + 시계방향 순차 펄스 */
|
||||
.inner-node {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(calc(-50% + var(--x)), calc(-50% + var(--y)));
|
||||
width: 86px;
|
||||
height: 86px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
border: 1px dashed rgba(170, 180, 230, 0.48);
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
z-index: 6;
|
||||
animation: inner-pulse 4.8s ease-in-out infinite;
|
||||
}
|
||||
@keyframes inner-pulse {
|
||||
0%, 70%, 100% {
|
||||
border-color: rgba(170, 180, 230, 0.48);
|
||||
box-shadow: none;
|
||||
}
|
||||
10%, 20% {
|
||||
border-color: rgba(180, 165, 250, 0.95);
|
||||
box-shadow:
|
||||
0 0 18px rgba(108, 92, 231, 0.45),
|
||||
inset 0 0 12px rgba(108, 92, 231, 0.12);
|
||||
}
|
||||
}
|
||||
.inner-node .ic { transition: color 0.5s; }
|
||||
.inner-node .ic { color: rgba(255, 255, 255, 0.78); display: flex; }
|
||||
.inner-node .ic svg { width: 22px; height: 22px; stroke-width: 1.4; }
|
||||
.inner-node .lb {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
/* @property로 scale 변수 transition 가능하게 (Chrome 85+) */
|
||||
@property --node-scale {
|
||||
syntax: '<number>';
|
||||
inherits: false;
|
||||
initial-value: 1;
|
||||
}
|
||||
/* 바깥 11 노드 카드 */
|
||||
.outer-node {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(calc(-50% + var(--x)), calc(-50% + var(--y))) scale(var(--node-scale));
|
||||
width: 168px;
|
||||
padding: 20px 14px 18px;
|
||||
background: linear-gradient(180deg, rgba(30, 32, 70, 0.72) 0%, rgba(18, 20, 50, 0.78) 100%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
z-index: 6;
|
||||
box-shadow: 0 2px 14px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255,255,255,0.03);
|
||||
transition: --node-scale 0.55s cubic-bezier(0.34, 1.56, 0.64, 1),
|
||||
border-color 0.45s, background 0.45s, box-shadow 0.45s;
|
||||
}
|
||||
.outer-node.selected {
|
||||
--node-scale: 1.08;
|
||||
border-color: rgba(155, 140, 250, 1);
|
||||
background: linear-gradient(180deg, rgba(54, 44, 135, 0.5) 0%, rgba(32, 26, 85, 0.5) 100%);
|
||||
box-shadow:
|
||||
0 0 28px rgba(108, 92, 231, 0.55),
|
||||
0 0 56px rgba(108, 92, 231, 0.22),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.outer-node.selected::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -8px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(155, 140, 250, 0.65);
|
||||
animation: pulse-ring 1.8s ease-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
.outer-node.selected::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
left: 50%;
|
||||
width: 50%;
|
||||
height: 1.5px;
|
||||
transform: translateX(-50%);
|
||||
background: linear-gradient(90deg, transparent, rgba(180, 165, 255, 1), transparent);
|
||||
box-shadow: 0 0 8px rgba(155, 140, 250, 0.8);
|
||||
pointer-events: none;
|
||||
}
|
||||
@keyframes pulse-ring {
|
||||
0% { transform: scale(0.95); opacity: 0.85; }
|
||||
100% { transform: scale(1.20); opacity: 0; }
|
||||
}
|
||||
.outer-node .ic { color: rgba(255, 255, 255, 0.88); display: flex; }
|
||||
.outer-node .ic svg { width: 34px; height: 34px; stroke-width: 1.3; }
|
||||
.outer-node.selected .ic { color: #fff; }
|
||||
.outer-node .en {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.005em;
|
||||
color: #fff;
|
||||
}
|
||||
.outer-node .ko {
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.outer-node.selected .ko { color: rgba(255, 255, 255, 0.82); }
|
||||
|
||||
/* 우하단 footer */
|
||||
.bottom-tag {
|
||||
position: absolute;
|
||||
bottom: 34px;
|
||||
right: 42px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.58);
|
||||
letter-spacing: 0.01em;
|
||||
z-index: 10;
|
||||
}
|
||||
.bottom-tag .plus { color: var(--purple-soft); font-weight: 600; font-size: 16px; }
|
||||
.bottom-tag .sep { opacity: 0.45; }
|
||||
|
||||
/* === 애니메이션 토글 패널 === */
|
||||
.control-panel {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
z-index: 1000;
|
||||
padding: 14px 16px;
|
||||
background: rgba(15, 15, 40, 0.92);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-family: 'Pretendard', system-ui, sans-serif;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-width: 200px;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.control-panel .cp-title {
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.18em;
|
||||
color: rgba(255,255,255,0.6);
|
||||
text-transform: uppercase;
|
||||
padding-bottom: 5px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.control-panel label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.control-panel input[type="checkbox"] { accent-color: #8b7df0; cursor: pointer; }
|
||||
.control-panel button {
|
||||
margin-top: 4px;
|
||||
padding: 7px 10px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.control-panel button:hover { background: rgba(255, 255, 255, 0.12); }
|
||||
.control-panel.hidden { display: none; }
|
||||
.show-panel-btn {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
z-index: 1000;
|
||||
padding: 8px 12px;
|
||||
background: rgba(15, 15, 40, 0.92);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-family: 'Pretendard', system-ui, sans-serif;
|
||||
cursor: pointer;
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* anim toggle overrides */
|
||||
body.anim-off-inner-pulse .inner-node { animation: none !important; }
|
||||
body.anim-off-orb-breathe .orb { animation: none !important; }
|
||||
body.anim-off-ring-1 .orb-ring:not(.reverse) { animation: none !important; }
|
||||
body.anim-off-ring-2 .orb-ring.reverse { animation: none !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="control-panel">
|
||||
<div class="cp-title">Animations</div>
|
||||
<label><input type="checkbox" data-anim="roulette" checked> 외곽 룰렛 회전</label>
|
||||
<label><input type="checkbox" data-anim="inner-pulse" checked> 안쪽 4개 펄스</label>
|
||||
<label><input type="checkbox" data-anim="orb-breathe" checked> orb 호흡</label>
|
||||
<label><input type="checkbox" data-anim="ring-1" checked> 큰 ring (시계)</label>
|
||||
<label><input type="checkbox" data-anim="ring-2" checked> 작은 ring (반시계)</label>
|
||||
<button id="record-btn">⏺ 녹화 시작 (82초)</button>
|
||||
<button id="hide-panel">패널 숨기기</button>
|
||||
</div>
|
||||
<button class="show-panel-btn" id="show-panel">⚙ 애니메이션 패널</button>
|
||||
|
||||
<div class="stage">
|
||||
<div class="dot-bg"></div>
|
||||
|
||||
<div class="top-badge">
|
||||
<span class="badge-dot"></span>
|
||||
<span>NO-CODE · BUILD ANYTHING</span>
|
||||
</div>
|
||||
|
||||
<!-- 외곽 점선 원 r=460 + 노드 사이 작은 dot 11개 (11등분 균등) -->
|
||||
<svg class="ring-svg" width="1000" height="1000" viewBox="-500 -500 1000 1000">
|
||||
<circle cx="0" cy="0" r="460" fill="none" stroke="rgba(170, 180, 230, 0.30)" stroke-width="1" stroke-dasharray="2 6" />
|
||||
<g fill="rgba(165, 180, 230, 0.55)">
|
||||
<!-- 인접 노드 호의 중간 (360/11 * (k+0.5)) -->
|
||||
<circle cx="129.6" cy="-441.4" r="3.5" />
|
||||
<circle cx="347.6" cy="-301.2" r="3.5" />
|
||||
<circle cx="455.3" cy="-65.5" r="3.5" />
|
||||
<circle cx="418.4" cy="191.1" r="3.5" />
|
||||
<circle cx="248.7" cy="387" r="3.5" />
|
||||
<circle cx="0" cy="460" r="3.5" />
|
||||
<circle cx="-248.7" cy="387" r="3.5" />
|
||||
<circle cx="-418.4" cy="191.1" r="3.5" />
|
||||
<circle cx="-455.3" cy="-65.5" r="3.5" />
|
||||
<circle cx="-347.6" cy="-301.2" r="3.5" />
|
||||
<circle cx="-129.6" cy="-441.4" r="3.5" />
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<!-- 안쪽 점선 원 r=240 -->
|
||||
<svg class="ring-svg" width="600" height="600" viewBox="-300 -300 600 600">
|
||||
<circle cx="0" cy="0" r="240" fill="none" stroke="rgba(170, 180, 230, 0.22)" stroke-width="1" stroke-dasharray="2 6" />
|
||||
</svg>
|
||||
|
||||
<!-- 중앙 펄스 -->
|
||||
<div class="orb-pulse p3"></div>
|
||||
<div class="orb-pulse p2"></div>
|
||||
<div class="orb-pulse p1"></div>
|
||||
|
||||
<!-- 회전 점선 ring 2개 (반대 방향) -->
|
||||
<svg class="orb-ring" width="380" height="380" viewBox="-190 -190 380 380">
|
||||
<circle r="178" cx="0" cy="0" fill="none"
|
||||
stroke="rgba(180, 165, 250, 0.32)" stroke-width="1" stroke-dasharray="3 9" />
|
||||
</svg>
|
||||
<svg class="orb-ring reverse" width="340" height="340" viewBox="-170 -170 340 340">
|
||||
<circle r="158" cx="0" cy="0" fill="none"
|
||||
stroke="rgba(180, 165, 250, 0.2)" stroke-width="1" stroke-dasharray="2 6" />
|
||||
</svg>
|
||||
|
||||
<!-- 중앙 글로우 원 -->
|
||||
<div class="orb">
|
||||
<div class="orb-name">Invyone</div>
|
||||
<div class="orb-tag">N O - C O D E</div>
|
||||
</div>
|
||||
|
||||
<!-- 안쪽 4 노드 (반경 240, 12/3/6/9시) -->
|
||||
<div class="inner-node" style="--x: 0px; --y: -240px">
|
||||
<div class="ic"><i data-lucide="file-lock-2"></i></div>
|
||||
<div class="lb">PTW/LOTO</div>
|
||||
</div>
|
||||
<div class="inner-node" style="--x: 240px; --y: 0px">
|
||||
<div class="ic"><i data-lucide="trending-up"></i></div>
|
||||
<div class="lb">KPI/Report</div>
|
||||
</div>
|
||||
<div class="inner-node" style="--x: 0px; --y: 240px">
|
||||
<div class="ic"><i data-lucide="user-round"></i></div>
|
||||
<div class="lb">HRM</div>
|
||||
</div>
|
||||
<div class="inner-node" style="--x: -240px; --y: 0px">
|
||||
<div class="ic"><i data-lucide="settings"></i></div>
|
||||
<div class="lb">Workflow</div>
|
||||
</div>
|
||||
|
||||
<!-- 바깥 11 노드 (반경 460, 11등분 균등 32.73°씩) -->
|
||||
<!-- 0° MES -->
|
||||
<div class="outer-node" style="--x: 0px; --y: -460px">
|
||||
<div class="ic"><i data-lucide="factory"></i></div>
|
||||
<div class="en">MES</div>
|
||||
<div class="ko">생산관리</div>
|
||||
</div>
|
||||
<!-- 32.73° Inventory -->
|
||||
<div class="outer-node" style="--x: 248.7px; --y: -387px">
|
||||
<div class="ic"><i data-lucide="package"></i></div>
|
||||
<div class="en">Inventory</div>
|
||||
<div class="ko">재고관리</div>
|
||||
</div>
|
||||
<!-- 65.45° PLM -->
|
||||
<div class="outer-node" style="--x: 418.4px; --y: -191.1px">
|
||||
<div class="ic"><i data-lucide="wrench"></i></div>
|
||||
<div class="en">PLM</div>
|
||||
<div class="ko">제품수명</div>
|
||||
</div>
|
||||
<!-- 98.18° AI Agents -->
|
||||
<div class="outer-node" style="--x: 455.3px; --y: 65.5px">
|
||||
<div class="ic"><i data-lucide="bot"></i></div>
|
||||
<div class="en">AI Agents</div>
|
||||
<div class="ko">자동화</div>
|
||||
</div>
|
||||
<!-- 130.91° EHS -->
|
||||
<div class="outer-node" style="--x: 347.6px; --y: 301.2px">
|
||||
<div class="ic"><i data-lucide="hard-hat"></i></div>
|
||||
<div class="en">EHS</div>
|
||||
<div class="ko">환경안전</div>
|
||||
</div>
|
||||
<!-- 163.64° ERP -->
|
||||
<div class="outer-node" style="--x: 129.6px; --y: 441.4px">
|
||||
<div class="ic"><i data-lucide="briefcase"></i></div>
|
||||
<div class="en">ERP</div>
|
||||
<div class="ko">전사자원</div>
|
||||
</div>
|
||||
<!-- 196.36° WMS -->
|
||||
<div class="outer-node" style="--x: -129.6px; --y: 441.4px">
|
||||
<div class="ic"><i data-lucide="truck"></i></div>
|
||||
<div class="en">WMS</div>
|
||||
<div class="ko">창고관리</div>
|
||||
</div>
|
||||
<!-- 229.09° QMS -->
|
||||
<div class="outer-node" style="--x: -347.6px; --y: 301.2px">
|
||||
<div class="ic"><i data-lucide="shield-check"></i></div>
|
||||
<div class="en">QMS</div>
|
||||
<div class="ko">품질관리</div>
|
||||
</div>
|
||||
<!-- 261.82° SCM -->
|
||||
<div class="outer-node" style="--x: -455.3px; --y: 65.5px">
|
||||
<div class="ic"><i data-lucide="globe"></i></div>
|
||||
<div class="en">SCM</div>
|
||||
<div class="ko">공급망</div>
|
||||
</div>
|
||||
<!-- 294.55° CRM -->
|
||||
<div class="outer-node" style="--x: -418.4px; --y: -191.1px">
|
||||
<div class="ic"><i data-lucide="users"></i></div>
|
||||
<div class="en">CRM</div>
|
||||
<div class="ko">고객관리</div>
|
||||
</div>
|
||||
<!-- 327.27° SCADA -->
|
||||
<div class="outer-node" style="--x: -248.7px; --y: -387px">
|
||||
<div class="ic"><i data-lucide="activity"></i></div>
|
||||
<div class="en">SCADA</div>
|
||||
<div class="ko">공정감시</div>
|
||||
</div>
|
||||
|
||||
<!-- 우하단 카피 -->
|
||||
<div class="bottom-tag">
|
||||
<span class="plus">+</span>
|
||||
<span>Build Anything</span>
|
||||
<span class="sep">·</span>
|
||||
<span>원하는 대로 자유롭게</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.js"></script>
|
||||
<script>
|
||||
lucide.createIcons();
|
||||
|
||||
// 안쪽 4 노드 — 시계방향 순차 펄스 (1.2초씩 어긋나게)
|
||||
document.querySelectorAll('.inner-node').forEach((node, i) => {
|
||||
node.style.animationDelay = (i * 1.2) + 's';
|
||||
});
|
||||
|
||||
// === 외곽 룰렛 회전 + 랜덤 선택 ===
|
||||
const TOTAL_NODES = 11;
|
||||
const RADIUS = 460;
|
||||
const STEP = 360 / TOTAL_NODES;
|
||||
const outerNodes = document.querySelectorAll('.outer-node');
|
||||
let currentAngle = 0;
|
||||
let lastTargetIndex = -1;
|
||||
let rouletteEnabled = true;
|
||||
let rafId = null;
|
||||
let highlightTimerId = null;
|
||||
let nextSpinTimerId = null;
|
||||
|
||||
function setNodePositions(angle) {
|
||||
for (let i = 0; i < outerNodes.length; i++) {
|
||||
const a = (i * STEP + angle) * Math.PI / 180;
|
||||
const x = Math.sin(a) * RADIUS;
|
||||
const y = -Math.cos(a) * RADIUS;
|
||||
outerNodes[i].style.setProperty('--x', x.toFixed(1) + 'px');
|
||||
outerNodes[i].style.setProperty('--y', y.toFixed(1) + 'px');
|
||||
}
|
||||
}
|
||||
|
||||
function spinAndPick() {
|
||||
if (!rouletteEnabled) return;
|
||||
|
||||
// === 녹화 트리거 (recorder.start는 외부에서 이미 호출됨) ===
|
||||
if (recordingActive) {
|
||||
recordingCycleCount++;
|
||||
if (recordingCycleCount > RECORD_CYCLES) {
|
||||
recorder.stop();
|
||||
recordingActive = false;
|
||||
} else {
|
||||
recordBtn.textContent = '⏺ 녹화 중 ' + recordingCycleCount + '/' + RECORD_CYCLES;
|
||||
}
|
||||
}
|
||||
|
||||
const currMod = ((currentAngle % 360) + 360) % 360;
|
||||
// 지금 12시 위치에 있는 노드 인덱스 (정규화)
|
||||
const currentIndex = ((Math.round(-currMod / STEP) % TOTAL_NODES) + TOTAL_NODES) % TOTAL_NODES;
|
||||
|
||||
// 시계방향 1칸씩 sequential — 11 cycle 후 처음 노드로 자동 복귀 (seamless loop 보장)
|
||||
const stepsAhead = 1;
|
||||
const target = (currentIndex - stepsAhead + TOTAL_NODES) % TOTAL_NODES;
|
||||
lastTargetIndex = target;
|
||||
|
||||
const targetMod = ((-target * STEP) % 360 + 360) % 360;
|
||||
let delta = targetMod - currMod;
|
||||
if (delta <= 0) delta += 360;
|
||||
|
||||
const startAngle = currentAngle;
|
||||
const duration = 3000;
|
||||
const startTime = performance.now();
|
||||
|
||||
outerNodes.forEach(n => n.classList.remove('selected'));
|
||||
|
||||
function frame(t) {
|
||||
const p = Math.min((t - startTime) / duration, 1);
|
||||
const eased = 1 - Math.pow(1 - p, 1.3); // 거의 linear, 끝만 살짝 감속
|
||||
currentAngle = startAngle + delta * eased;
|
||||
setNodePositions(currentAngle);
|
||||
if (p < 1) {
|
||||
rafId = requestAnimationFrame(frame);
|
||||
} else {
|
||||
outerNodes[target].classList.add('selected');
|
||||
highlightTimerId = setTimeout(() => {
|
||||
outerNodes[target].classList.remove('selected');
|
||||
if (rouletteEnabled) nextSpinTimerId = setTimeout(spinAndPick, 500);
|
||||
}, 4000);
|
||||
}
|
||||
}
|
||||
rafId = requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
setNodePositions(0);
|
||||
setTimeout(() => { if (rouletteEnabled) spinAndPick(); }, 1200);
|
||||
|
||||
// === 애니메이션 토글 패널 핸들러 ===
|
||||
const panel = document.querySelector('.control-panel');
|
||||
const showBtn = document.getElementById('show-panel');
|
||||
|
||||
panel.querySelectorAll('input[data-anim]').forEach(input => {
|
||||
input.addEventListener('change', () => {
|
||||
const anim = input.dataset.anim;
|
||||
const on = input.checked;
|
||||
if (anim === 'roulette') {
|
||||
rouletteEnabled = on;
|
||||
if (on) setTimeout(spinAndPick, 200);
|
||||
} else {
|
||||
document.body.classList.toggle('anim-off-' + anim, !on);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('hide-panel').addEventListener('click', () => {
|
||||
panel.classList.add('hidden');
|
||||
showBtn.style.display = 'block';
|
||||
});
|
||||
showBtn.addEventListener('click', () => {
|
||||
panel.classList.remove('hidden');
|
||||
showBtn.style.display = 'none';
|
||||
});
|
||||
|
||||
// === 자동 녹화 (MediaRecorder + getDisplayMedia) ===
|
||||
let recorder, recordChunks;
|
||||
let recordingActive = false;
|
||||
let recordingCycleCount = 0;
|
||||
const RECORD_CYCLES = 11; // 11 cycle = seamless loop
|
||||
|
||||
const recordBtn = document.getElementById('record-btn');
|
||||
recordBtn.addEventListener('click', async () => {
|
||||
if (recordingActive) return;
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getDisplayMedia({
|
||||
video: {
|
||||
displaySurface: 'browser',
|
||||
frameRate: 30
|
||||
},
|
||||
audio: false,
|
||||
preferCurrentTab: true,
|
||||
selfBrowserSurface: 'include'
|
||||
});
|
||||
|
||||
// Region Capture — stage element 영역만 crop (Chrome 104+)
|
||||
const [videoTrack] = stream.getVideoTracks();
|
||||
try {
|
||||
if (typeof CropTarget !== 'undefined' && CropTarget.fromElement && videoTrack.cropTo) {
|
||||
const cropTarget = await CropTarget.fromElement(document.querySelector('.stage'));
|
||||
await videoTrack.cropTo(cropTarget);
|
||||
console.log('Region Capture 활성화: stage 영역만 캡쳐됩니다.');
|
||||
} else {
|
||||
console.warn('Region Capture 미지원 — 탭 전체가 캡쳐됩니다.');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Region Capture 실패:', e);
|
||||
}
|
||||
|
||||
const mimeType = MediaRecorder.isTypeSupported('video/webm;codecs=vp9')
|
||||
? 'video/webm;codecs=vp9'
|
||||
: 'video/webm';
|
||||
recorder = new MediaRecorder(stream, { mimeType, videoBitsPerSecond: 8_000_000 });
|
||||
recordChunks = [];
|
||||
recorder.ondataavailable = e => { if (e.data.size > 0) recordChunks.push(e.data); };
|
||||
recorder.onstop = () => {
|
||||
const blob = new Blob(recordChunks, { type: 'video/webm' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'invyone-intro-' + new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-') + '.webm';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
stream.getTracks().forEach(t => t.stop());
|
||||
recordBtn.textContent = '⏺ 녹화 시작 (82초)';
|
||||
recordBtn.disabled = false;
|
||||
};
|
||||
|
||||
// === 애니메이션 처음 위치로 reset (MES가 12시) ===
|
||||
cancelAnimationFrame(rafId);
|
||||
clearTimeout(highlightTimerId);
|
||||
clearTimeout(nextSpinTimerId);
|
||||
outerNodes.forEach(n => n.classList.remove('selected'));
|
||||
currentAngle = 0;
|
||||
setNodePositions(0);
|
||||
|
||||
recordingActive = true;
|
||||
recordingCycleCount = 0;
|
||||
recordBtn.disabled = true;
|
||||
|
||||
// 녹화 즉시 시작 (정적 1.5초도 영상에 포함 → loop 시 자연스럽게 이어짐)
|
||||
recorder.start();
|
||||
recordBtn.textContent = '⏺ 녹화 중... 정적 1.5초';
|
||||
|
||||
// 1.5초 정적 후 첫 cycle 시작
|
||||
setTimeout(spinAndPick, 1500);
|
||||
} catch (err) {
|
||||
console.error('녹화 권한 거부:', err);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user