Files
invyone/frontend/lib/registry/components/stats/InvStatsConfigPanel.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

941 lines
32 KiB
TypeScript

"use client";
/**
* InvStatsConfigPanel — 통합 "통계 카드" (id: stats) cp 톤 설정 패널
*
* Phase G.4.2 — **DB-first KPI editor**.
*
* stats 는 KPI / 집계 컴포넌트다. 본 역할:
* - 테이블 선택
* - 집계 선택
* - (필요 시) 집계 컬럼 선택
* - 필터 적용
* - 결과 숫자 표시
*
* 그래서 항목 default 자체가 `{ label, dataSource: { aggregation: "count" } }` 이며,
* fallback value / 변화량 (delta) 는 모두 "고급" 섹션 안의 보조 옵션이다. 메인 흐름에
* 직접 노출되지 않는다.
*
* 흐름:
* ① 기본 — 제목
* ② 배치 + 스타일
* ③ 색상 프리셋
* ④ 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,
Filter as FilterIcon,
ChevronRight,
} from "lucide-react";
import {
CPSection,
CPRow,
CPText,
CPSelect,
CPSegment,
CPNumber,
CPColor,
CPVisualGrid,
Hint,
SectionLabel,
} from "@/components/v2/config-panels/_shared/cp";
import { IconPicker } from "../common/IconPicker";
import { useDbTables } from "../common/useDbTables";
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"] },
{ id: "rainbow", name: "다채로운", colors: ["#3b82f6", "#10b981", "#f59e0b", "#ef4444"] },
{ id: "pastel", name: "파스텔", colors: ["#93c5fd", "#86efac", "#fcd34d", "#fca5a5"] },
{ 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;
selectedComponent?: { id: string; config?: StatsConfig; [k: string]: any };
tables?: any[];
tableColumns?: any[];
screenTableName?: string;
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,
}) => {
useKeyframesOnce();
const current: StatsConfig =
(config as StatsConfig) || (selectedComponent?.config as StatsConfig) || {};
const patch = (p: Partial<StatsConfig>) => onChange?.({ ...current, ...p });
const items: StatsItem[] = current.items ?? [];
const updateItem = (idx: number, item: Partial<StatsItem>) => {
const next = items.map((it, i) => (i === idx ? { ...it, ...item } : it));
patch({ items: next });
};
const addItem = () => {
patch({ items: [...items, blankItem(items.length + 1)] });
};
const removeItem = (idx: number) => {
patch({ items: items.filter((_, i) => i !== idx) });
};
const { options: tableOptions } = useDbTables();
const orientation = current.orientation || "horizontal";
const styleMode = current.style || "card";
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="이번 달 KPI"
/>
</CPRow>
</CPSection>
{/* ── ② 배치 + 스타일 ─────────────────────────── */}
<CPSection title="② 배치 + 스타일" desc="항목을 어떻게 보여줄지">
<CPRow label="배치">
<CPSegment
value={orientation}
onChange={(v) => patch({ orientation: v as StatsConfig["orientation"] })}
options={[
{ value: "horizontal", label: "가로" },
{ value: "vertical", label: "세로" },
{ value: "grid", label: "그리드" },
]}
/>
</CPRow>
{orientation === "grid" && (
<CPRow label="그리드 열" help="1 ~ 8">
<CPNumber
value={current.columns ?? 4}
onChange={(v) => patch({ columns: v ?? 4 })}
min={1}
max={8}
/>
</CPRow>
)}
<div style={{ marginTop: 8 }}>
<CPRow label="표시 스타일" />
<CPVisualGrid
cols={3}
cardHeight={52}
value={styleMode}
onChange={(v) => patch({ style: v as StatsConfig["style"] })}
options={[
{
value: "card",
label: "카드",
preview: <CardPreview />,
desc: "박스 + 보더 + 라벨/값",
},
{
value: "chip",
label: "칩",
preview: <ChipPreview />,
desc: "둥근 pill 형태",
},
{
value: "bigNumber",
label: "큰 숫자",
preview: <BigNumberPreview />,
desc: "값을 크게 강조",
},
]}
/>
</div>
</CPSection>
{/* ── ③ 색상 프리셋 ─────────────────────────── */}
<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) {
patch({
items: preset.colors.map((c, i) => ({
...blankItem(i + 1),
color: c,
})),
});
} else {
const updated = items.map((it, i) => ({
...it,
color: preset.colors[i % preset.colors.length],
}));
patch({ items: updated });
}
}}
style={{
display: "flex",
alignItems: "center",
gap: 7,
padding: "5px 8px",
background: "var(--cp-bg-subtle)",
border: "1px solid var(--cp-border-subtle)",
borderRadius: 5,
cursor: "pointer",
fontFamily: "var(--v5-font-sans)",
transition: "background .12s ease, border-color .12s ease",
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLButtonElement).style.borderColor =
"hsl(var(--primary) / 0.4)";
(e.currentTarget as HTMLButtonElement).style.background =
"var(--cp-surface-hover, var(--cp-surface))";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLButtonElement).style.borderColor =
"var(--cp-border-subtle)";
(e.currentTarget as HTMLButtonElement).style.background =
"var(--cp-bg-subtle)";
}}
>
<span style={{ display: "inline-flex", gap: 2 }}>
{preset.colors.map((c) => (
<span
key={c}
style={{
width: 14,
height: 14,
borderRadius: 3,
background: c,
boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.06)",
}}
/>
))}
</span>
<span style={{ fontSize: 10.5, fontWeight: 600, color: "var(--cp-text-sec)", letterSpacing: "-0.005em" }}>
{preset.name}
</span>
</button>
))}
</div>
<Hint>
KPI 4 + . .
</Hint>
</CPSection>
{/* ── ④ KPI 항목 ─────────────────────────── */}
<CPSection title="④ KPI 항목" desc={`${items.length}`}>
<div style={{ display: "flex", justifyContent: "flex-end", marginBottom: 5 }}>
<button
type="button"
onClick={addItem}
style={{
padding: "4px 10px",
fontSize: 10.5,
background: "var(--cp-bg-subtle)",
border: "1px solid var(--cp-border)",
borderRadius: 4,
cursor: "pointer",
color: "var(--cp-text)",
fontFamily: "var(--v5-font-sans)",
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} />
</button>
</div>
{items.length === 0 ? (
<Hint>
KPI . [+ ] [ ] 4 .
</Hint>
) : (
<div
style={{
border: "1px solid var(--cp-border-subtle)",
borderRadius: 5,
overflow: "hidden",
background: "var(--cp-bg-subtle)",
}}
>
{items.map((item, idx) => (
<ItemEditRow
key={idx}
index={idx}
item={item}
isLast={idx === items.length - 1}
tableOptions={tableOptions}
onChange={(p) => updateItem(idx, p)}
onRemove={() => removeItem(idx)}
/>
))}
</div>
)}
</CPSection>
</div>
);
};
InvStatsConfigPanel.displayName = "InvStatsConfigPanel";
// ───────────────────────────────────────────────────────
// preview cards (시각 스타일 미리보기)
// ───────────────────────────────────────────────────────
function CardPreview() {
return (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
gap: 1,
padding: "3px 6px",
background: "var(--cp-surface)",
border: "1px solid var(--cp-border)",
borderRadius: 3,
minWidth: 36,
}}
>
<span style={{ fontSize: 7, color: "var(--cp-text-muted)" }}></span>
<span style={{ fontSize: 11, fontWeight: 700, color: "var(--cp-text)" }}>1.2M</span>
</div>
);
}
function ChipPreview() {
return (
<div
style={{
display: "inline-flex",
alignItems: "center",
gap: 4,
padding: "2px 8px",
background: "hsl(var(--primary) / 0.15)",
color: "hsl(var(--primary))",
borderRadius: 999,
fontSize: 9,
fontWeight: 700,
}}
>
<span></span>
<span>1.2M</span>
</div>
);
}
function BigNumberPreview() {
return (
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", lineHeight: 1 }}>
<span style={{ fontSize: 17, fontWeight: 800, color: "var(--cp-text)" }}>1.2M</span>
<span style={{ fontSize: 7, color: "var(--cp-text-muted)", marginTop: 1 }}></span>
</div>
);
}
// ───────────────────────────────────────────────────────
// 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
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
style={{
borderBottom: isLast ? "none" : "1px solid var(--cp-border-subtle)",
// 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",
}}
>
{/* collapsed KPI — 단일 padded 컨테이너 + flex column gap. 일관된 간격. */}
<div
style={{
padding: "5px 8px",
display: "flex",
flexDirection: "column",
gap: 4,
}}
>
{/* 1줄 — 라벨 input + 삭제 (펼침 chevron 제거; 메타 chip 클릭으로 토글) */}
<div
style={{
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 || ""}
onChange={(v) => onChange({ icon: v || undefined })}
/>
</CPRow>
<CPRow label="값 색">
<CPColor
value={item.color || ""}
onChange={(v) => onChange({ color: v || undefined })}
/>
</CPRow>
{/* ── 고급 ───────────────────────────── */}
<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.value?.toString() ?? ""}
onChange={(v) =>
onChange({ value: v === "" ? undefined : isNaN(Number(v)) ? v : Number(v) })
}
placeholder="비우면 —"
/>
</CPRow>
</div>
)}
</div>
);
}
// ───────────────────────────────────────────────────────
// inline input/select styles (compact, collapsed row 용)
// ───────────────────────────────────────────────────────
function inputStyle({ mono = false }: { mono?: boolean } = {}): React.CSSProperties {
return {
height: 22,
padding: "0 6px",
fontSize: 11,
fontFamily: mono ? "var(--v5-font-mono)" : "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 InvStatsConfigPanel;