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>
941 lines
32 KiB
TypeScript
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;
|