Files
invyone/frontend/lib/registry/components/card-list/InvCardListConfigPanel.tsx
T
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

473 lines
14 KiB
TypeScript

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