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

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

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

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

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

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

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

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

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

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