3883031c0b
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>
161 lines
4.8 KiB
TypeScript
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 };
|
|
}
|