Files
DDD1542 3883031c0b 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>
2026-05-14 17:41:50 +09:00

161 lines
4.8 KiB
TypeScript

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 };
}