Files
invyone/frontend/lib/registry/components/stats/InvStatsConfigPanel.tsx
T
DDD1542 e347a75953
Build & Deploy to K8s / build-and-deploy (push) Successful in 5m32s
refactor: ConfigPanel hook/helper 추출 + IconPicker cp+Portal
- useDbTables: search/table/stats 의 DB 테이블 로드 hook (3 패널 중복 제거)
- TableConnectSection + AutoLoadButton: search/table 의 테이블 연결 섹션 + 자동 로드 버튼
- row-helpers: RowNumberBadge / RowExpandChevron / RowDeleteBtn (4 패널 dense list helper)
- IconPicker: shadcn 톤 -> cp 톤 (28px 트리거, focus glow, cp 변수)
- IconPicker popover: React Portal + position:fixed (부모 overflow:hidden 우회)
- input X버튼은 hoverBg={false} 로 silent visual change 원복

Codex (GPT-5.5) 와 매 단계 교차검증 후 진행
미완 후속 사항 (auto-flip / 외부 클릭 닫기 / z-index 표준화 등) 노트에 기록

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 11:25:06 +09:00

557 lines
18 KiB
TypeScript

"use client";
/**
* InvStatsConfigPanel — 통합 "통계 카드" (id: stats) cp 톤 설정 패널
*
* 흐름:
* ① 기본 — 제목
* ② 데이터 소스 — 테이블 (CPSelect)
* ③ 배치 + 스타일 — orientation (CPSegment) + grid 열수 + style (CPVisualGrid 시각)
* ④ 색상 프리셋 — 4개 swatch grid (한 번 클릭 → items[] 의 색 일괄 적용)
* ⑤ 항목 list — dense + 펼침 (라벨+값 한 줄, 펼치면 icon/색/델타)
*
* Reference: notes/gbpark/2026-04-28-cp-panel-standard.md
*/
import React, { useState } from "react";
import { Plus, TrendingUp, TrendingDown, Minus } from "lucide-react";
import {
CPSection,
CPRow,
CPText,
CPSelect,
CPSegment,
CPNumber,
CPColor,
CPVisualGrid,
Hint,
} from "@/components/v2/config-panels/_shared/cp";
import { IconPicker } from "../common/IconPicker";
import { useDbTables } from "../common/useDbTables";
import {
RowNumberBadge,
RowExpandChevron,
RowDeleteBtn,
} from "../common/row-helpers";
import type { StatsConfig, StatsItem } from "./types";
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"] },
];
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;
}
export const InvStatsConfigPanel: React.FC<InvStatsConfigPanelProps> = ({
config,
onChange,
selectedComponent,
screenTableName,
onTableChange,
}) => {
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,
{ label: `항목 ${items.length + 1}`, value: 0 },
],
});
};
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";
const sourceTable = current.sourceTable || screenTableName || "";
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="집계할 DB 테이블 (선택)">
<CPRow label="테이블">
<CPSelect
value={sourceTable}
onChange={(v) => {
onTableChange?.(v);
patch({ sourceTable: v || undefined });
}}
>
<option value="">...</option>
{tableOptions.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</CPSelect>
</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) {
// 항목이 없으면 4개 만들고 색 적용
patch({
items: preset.colors.map((c, i) => ({
label: `항목 ${i + 1}`,
value: 0,
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 =
"rgba(var(--v5-primary-rgb), 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>
4 + . .
</Hint>
</CPSection>
{/* ── ⑤ 항목 list ─────────────────────────── */}
<CPSection title="⑤ 항목" 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,
}}
>
<Plus size={10} />
</button>
</div>
{items.length === 0 ? (
<Hint>
. [+ ] [ ] 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}
onChange={(p) => updateItem(idx, p)}
onRemove={() => removeItem(idx)}
/>
))}
</div>
)}
</CPSection>
</div>
);
};
InvStatsConfigPanel.displayName = "InvStatsConfigPanel";
// ───────────────────────────────────────────────────────
// CardPreview / ChipPreview / BigNumberPreview — 표시 스타일 미리보기
// ───────────────────────────────────────────────────────
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: "rgba(var(--v5-primary-rgb), 0.15)",
color: "var(--v5-primary, #6c5ce7)",
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 — 항목 한 줄 (라벨+값 + 펼침: icon/색/델타)
// ───────────────────────────────────────────────────────
function ItemEditRow({
index,
item,
isLast,
onChange,
onRemove,
}: {
index: number;
item: StatsItem;
isLast: boolean;
onChange: (p: Partial<StatsItem>) => void;
onRemove: () => void;
}) {
const [hover, setHover] = useState(false);
const [expanded, setExpanded] = useState(false);
return (
<div
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
style={{
borderBottom: isLast ? "none" : "1px solid var(--cp-border-subtle)",
background: hover ? "var(--cp-surface-hover, var(--cp-surface))" : "transparent",
transition: "background .12s ease",
fontFamily: "var(--v5-font-sans)",
}}
>
{/* 한 줄: # · color dot · 라벨 · 값 · 펼침 · 삭제 */}
<div
style={{
display: "grid",
gridTemplateColumns: "16px 10px 1fr 80px 18px 22px",
alignItems: "center",
columnGap: 6,
padding: "5px 8px",
minHeight: 28,
}}
>
<RowNumberBadge n={index + 1} />
<span
aria-hidden
style={{
width: 8,
height: 8,
borderRadius: 2,
background: item.color || "var(--cp-border-strong, var(--cp-border))",
boxShadow: item.color ? `0 0 4px ${item.color}55` : "none",
}}
/>
<input
type="text"
value={item.label}
onChange={(e) => onChange({ label: e.target.value })}
placeholder="라벨"
style={inputStyle()}
/>
<input
type="text"
value={item.value?.toString() ?? ""}
onChange={(e) => {
const v = e.target.value;
onChange({ value: isNaN(Number(v)) || v === "" ? v : Number(v) });
}}
placeholder="값"
style={inputStyle({ mono: true })}
/>
<RowExpandChevron
expanded={expanded}
onToggle={() => setExpanded((x) => !x)}
/>
<RowDeleteBtn onClick={onRemove} visible={hover} />
</div>
{expanded && (
<div
style={{
padding: "4px 8px 8px 30px",
display: "flex",
flexDirection: "column",
gap: 6,
}}
>
<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>
<CPRow label="변화량" help="예: +12.4% / -3 / 0">
<CPText
value={item.delta || ""}
onChange={(v) => onChange({ delta: v || undefined })}
placeholder="+12%"
/>
</CPRow>
<CPRow label="변화 방향">
<CPSegment
value={item.deltaDirection || "neutral"}
onChange={(v) =>
onChange({ deltaDirection: v as StatsItem["deltaDirection"] })
}
options={[
{
value: "up",
label: (
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: 2,
color: "#10b981",
}}
>
<TrendingUp size={11} />
</span>
),
},
{
value: "neutral",
label: (
<span style={{ display: "inline-flex", alignItems: "center", gap: 2 }}>
<Minus size={11} />
</span>
),
},
{
value: "down",
label: (
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: 2,
color: "#ef4444",
}}
>
<TrendingDown size={11} />
</span>
),
},
]}
/>
</CPRow>
</div>
)}
</div>
);
}
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;