e347a75953
Build & Deploy to K8s / build-and-deploy (push) Successful in 5m32s
- 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>
557 lines
18 KiB
TypeScript
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;
|