Files
invyone/frontend/lib/registry/components/button/InvButtonConfigPanel.tsx
T
DDD1542 a8ded6455d refactor: ConfigPanel Inv 네이밍 통합 + legacy 패널 분리 + input cp 마이그
11 패널 일괄 Inv* prefix 통일:
- 통합 (lib/registry/components/X/): button / container / divider / search /
  stats / table / title / input → Inv*ConfigPanel
- frontend/components/v2/config-panels/V2FieldConfigPanel → InvFieldConfigPanel
- 옛 v2-* hidden 호환 → InvLegacy{Divider,Text,Button}ConfigPanel

input 통합 컴포넌트 cp 톤 신규 작성 (InvInputConfigPanel):
- 277줄 옛 디자인 → CPVisualGrid 10칸 type 카드 + 타입별 옵션 + FeatureChipGrid

getComponentConfigPanel.tsx 버그 수정 (Codex 검토):
- "stats" key 중복 제거 (옛 StatsCardConfigPanel 이 통합 stats 덮던 silent bug)
- ALIAS 에서 v2-button-primary/v2-divider-line/v2-text-display 제외
  (옵션 B 일관성 — 옛 hidden 컴포넌트는 InvLegacy 패널 사용)
- MAP 의 해당 키를 InvLegacy* 로 직접 매핑

호출처 일괄 갱신:
- 각 통합 컴포넌트의 index.ts 7개 (import / config_panel / re-export)
- v2-input/v2-select/v2-divider-line/v2-text-display/v2-button-primary
  의 index.ts (config_panel 매핑)
- V2PropertiesPanel.tsx 의 require pattern (v2-input/v2-select)

검증: tsc 우리 영역 0건 / V2FieldConfigPanel 잔재 0건 / 기존 path 잔재 0건

다음 세션: useDbTables hook 추출 + 잔여 V2* cp 마이그 + dead code 정리

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:57:57 +09:00

430 lines
15 KiB
TypeScript

"use client";
/**
* InvButtonConfigPanel — 통합 "버튼" (id: button) cp 톤 설정 패널
*
* 흐름:
* ① 텍스트
* ② 스타일 — variant (CPVisualGrid 6칸 색 미리보기) + 크기 (CPSegment) + 색/모서리 (CPColor + CPText)
* ③ 액션 — actionType (CPVisualGrid 13칸 lucide icon) + 확인 메시지
* ④ 아이콘 — IconPicker + 아이콘 위치
* ▾ 고급 — 비활성화 (FeatureChipGrid)
*
* v2-button-primary 의 InvInvButtonConfigPanel 와 별개 (그쪽은 옛 컴포넌트의 풍부한 액션 옵션).
* 이 파일은 통합 "button" 컴포넌트의 단순한 패널.
*
* Reference: notes/gbpark/2026-04-28-cp-panel-standard.md
*/
import React, { useState, useEffect } from "react";
import {
Save,
Pencil,
Trash2,
Plus,
X,
XCircle,
ArrowRight,
Maximize2,
Search,
RotateCcw,
SendHorizontal,
Check,
Sparkles,
} from "lucide-react";
import {
CPSection,
CPRow,
CPGroup,
CPText,
CPColor,
CPSegment,
CPVisualGrid,
FeatureChipGrid,
Hint,
} from "@/components/v2/config-panels/_shared/cp";
import { IconPicker } from "../common/IconPicker";
import type { ButtonConfig } from "./types";
export interface InvButtonConfigPanelProps {
config?: ButtonConfig;
onChange?: (config: ButtonConfig) => void;
onUpdateProperty?: (componentId: string, path: string, value: unknown) => void;
selectedComponent?: { id: string; config?: ButtonConfig; [k: string]: any };
}
// ───────────────────────────────────────────────────────
// 시각 미리보기 헬퍼
// ───────────────────────────────────────────────────────
function VariantSwatch({
bg,
border,
textColor,
outline,
}: {
bg?: string;
border?: string;
textColor?: string;
outline?: boolean;
}) {
return (
<div
style={{
width: 36,
height: 18,
borderRadius: 4,
background: bg || "transparent",
border: border || (outline ? "1px solid currentColor" : "1px solid transparent"),
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 9.5,
fontWeight: 700,
color: textColor || "currentColor",
}}
>
Aa
</div>
);
}
const VARIANT_OPTIONS = [
{ value: "primary", label: "주", swatch: <VariantSwatch bg="#6c5ce7" textColor="#fff" /> },
{ value: "secondary", label: "보조", swatch: <VariantSwatch bg="#94a3b8" textColor="#fff" /> },
{ value: "default", label: "기본", swatch: <VariantSwatch bg="#e5e7eb" textColor="#111" /> },
{
value: "destructive",
label: "위험",
swatch: <VariantSwatch bg="#ef4444" textColor="#fff" />,
},
{ value: "outline", label: "외곽선", swatch: <VariantSwatch outline /> },
{ value: "ghost", label: "투명", swatch: <VariantSwatch /> },
];
const SIZE_OPTIONS = [
{ value: "sm", label: "작게" },
{ value: "md", label: "보통" },
{ value: "lg", label: "크게" },
];
interface ActionDef {
value: NonNullable<ButtonConfig["actionType"]>;
label: string;
icon: React.ReactNode;
miniIcon: React.ReactNode;
desc?: string;
color: string; // mini button preview 색
}
const ACTION_DEFS: Record<string, ActionDef> = {
save: { value: "save", label: "저장", icon: <Save size={14} />, miniIcon: <Save size={10} />, desc: "데이터 저장", color: "#3b82f6" },
edit: { value: "edit", label: "수정", icon: <Pencil size={14} />, miniIcon: <Pencil size={10} />, desc: "데이터 수정", color: "#3b82f6" },
delete: { value: "delete", label: "삭제", icon: <Trash2 size={14} />, miniIcon: <Trash2 size={10} />, desc: "데이터 삭제", color: "#ef4444" },
add: { value: "add", label: "등록", icon: <Plus size={14} />, miniIcon: <Plus size={10} />, desc: "새 항목 추가", color: "#10b981" },
submit: { value: "submit", label: "제출", icon: <SendHorizontal size={14} />, miniIcon: <SendHorizontal size={10} />, desc: "폼 제출", color: "#3b82f6" },
approval: { value: "approval", label: "승인", icon: <Check size={14} />, miniIcon: <Check size={10} />, desc: "승인 처리", color: "#10b981" },
reset: { value: "reset", label: "초기화", icon: <RotateCcw size={14} />, miniIcon: <RotateCcw size={10} />, desc: "값 리셋", color: "#94a3b8" },
cancel: { value: "cancel", label: "취소", icon: <X size={14} />, miniIcon: <X size={10} />, desc: "변경 취소", color: "#94a3b8" },
close: { value: "close", label: "닫기", icon: <XCircle size={14} />, miniIcon: <XCircle size={10} />, desc: "모달/창 닫기", color: "#94a3b8" },
navigate: { value: "navigate", label: "이동", icon: <ArrowRight size={14} />, miniIcon: <ArrowRight size={10} />, desc: "다른 화면", color: "#94a3b8" },
popup: { value: "popup", label: "팝업", icon: <Maximize2 size={14} />, miniIcon: <Maximize2 size={10} />, desc: "팝업 열기", color: "#94a3b8" },
search: { value: "search", label: "검색", icon: <Search size={14} />, miniIcon: <Search size={10} />, desc: "조회 실행", color: "#8b5cf6" },
custom: { value: "custom", label: "커스텀", icon: <Sparkles size={14} />, miniIcon: <Sparkles size={10} />, desc: "사용자 정의", color: "#8b5cf6" },
};
const ACTION_GROUPS: Array<{ id: string; name: string; items: string[] }> = [
{ id: "data", name: "데이터", items: ["save", "edit", "delete", "add"] },
{ id: "flow", name: "흐름", items: ["submit", "approval", "reset"] },
{ id: "display", name: "표시", items: ["cancel", "close", "navigate", "popup"] },
{ id: "etc", name: "기타", items: ["search", "custom"] },
];
// ───────────────────────────────────────────────────────
// Main
// ───────────────────────────────────────────────────────
export const InvButtonConfigPanel: React.FC<InvButtonConfigPanelProps> = ({
config,
onChange,
selectedComponent,
}) => {
const current: ButtonConfig =
(config as ButtonConfig) || (selectedComponent?.config as ButtonConfig) || {};
const patch = (p: Partial<ButtonConfig>) => onChange?.({ ...current, ...p });
const variant = current.variant || "primary";
const size = current.size || "md";
const actionType = current.actionType || "save";
return (
<div style={{ fontFamily: "var(--v5-font-sans)", color: "var(--cp-text)", padding: "0 12px" }}>
{/* ── ① 텍스트 ─────────────────────────── */}
<CPSection title="① 텍스트">
<CPRow label="버튼 라벨">
<CPText
value={current.text || ""}
onChange={(v) => patch({ text: v })}
placeholder="버튼"
/>
</CPRow>
</CPSection>
{/* ── ② 스타일 ─────────────────────────── */}
<CPSection title="② 스타일" desc="모양과 색">
<CPRow label="변형" />
<CPVisualGrid
cols={3}
cardHeight={56}
value={variant}
onChange={(v) => patch({ variant: v as ButtonConfig["variant"] })}
options={VARIANT_OPTIONS.map((o) => ({
value: o.value,
label: o.label,
preview: o.swatch,
}))}
/>
<div style={{ marginTop: 10 }}>
<CPRow label="크기">
<CPSegment
value={size}
onChange={(v) => patch({ size: v as ButtonConfig["size"] })}
options={SIZE_OPTIONS.map((o) => ({ value: o.value, label: o.label }))}
/>
</CPRow>
<CPRow label="모서리 반경" help="예: 6px / 8px / 999px(원형)">
<CPText
value={current.borderRadius || "6px"}
onChange={(v) => patch({ borderRadius: v })}
placeholder="6px"
/>
</CPRow>
<CPRow label="배경 색" help="비우면 variant 색 사용">
<CPColor
value={current.backgroundColor || ""}
onChange={(v) => patch({ backgroundColor: v || undefined })}
/>
</CPRow>
<CPRow label="텍스트 색" help="비우면 variant 색 사용">
<CPColor
value={current.textColor || ""}
onChange={(v) => patch({ textColor: v || undefined })}
/>
</CPRow>
</div>
</CPSection>
{/* ── ③ 액션 ─────────────────────────── */}
<CPSection title="③ 액션" desc="이 버튼이 무엇을 실행하는가">
<ActionCardGrid
value={actionType}
onChange={(v) => patch({ actionType: v as ButtonConfig["actionType"] })}
/>
<div style={{ marginTop: 12 }}>
<CPRow label="확인 메시지" help="비우면 즉시 실행">
<CPText
value={current.confirm || ""}
onChange={(v) => patch({ confirm: v || undefined })}
placeholder="정말 진행하시겠습니까?"
/>
</CPRow>
</div>
</CPSection>
{/* ── ④ 아이콘 ─────────────────────────── */}
<CPSection title="④ 아이콘">
<CPRow label="아이콘">
<IconPicker
value={current.icon}
onChange={(v) => patch({ icon: v || undefined })}
/>
</CPRow>
{current.icon && (
<CPRow label="위치">
<CPSegment
value={current.iconPosition || "left"}
onChange={(v) => patch({ iconPosition: v as "left" | "right" })}
options={[
{ value: "left", label: "왼쪽" },
{ value: "right", label: "오른쪽" },
]}
/>
</CPRow>
)}
{!current.icon && <Hint> .</Hint>}
</CPSection>
{/* ── ▾ 고급 설정 ─────────────────────────── */}
<CPGroup title="고급 설정" defaultOpen={false}>
<FeatureChipGrid
items={[
{
key: "disabled",
label: "비활성화",
desc: "버튼을 회색으로 흐리게 표시하고 클릭을 차단합니다.\n조건부 표시와 함께 자주 쓰여요.",
},
]}
source={current as any}
onToggle={(k, v) => patch({ [k]: v } as Partial<ButtonConfig>)}
/>
</CPGroup>
</div>
);
};
InvButtonConfigPanel.displayName = "InvButtonConfigPanel";
// ───────────────────────────────────────────────────────
// ActionCardGrid — 2단계 선택 (그룹 segment + 그 안 chip)
//
// 위 한 줄 = 그룹 segment (데이터/흐름/표시/기타)
// 아래 한 줄 = 선택된 그룹의 액션 chip (wrap)
//
// 로직:
// - 초기 active 그룹 = 현재 actionType 이 속한 그룹
// - 사용자가 그룹 클릭 → 필터만 변경 (actionType 그대로)
// - 액션 chip 클릭 → actionType 변경 + 그룹 자동 sync
// ───────────────────────────────────────────────────────
function ActionCardGrid({
value,
onChange,
}: {
value: string;
onChange: (v: string) => void;
}) {
const initialGroup =
ACTION_GROUPS.find((g) => g.items.includes(value))?.id || ACTION_GROUPS[0].id;
const [filterGroup, setFilterGroup] = useState(initialGroup);
useEffect(() => {
const g = ACTION_GROUPS.find((gg) => gg.items.includes(value));
if (g && g.id !== filterGroup) setFilterGroup(g.id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]);
const currentGroup =
ACTION_GROUPS.find((g) => g.id === filterGroup) || ACTION_GROUPS[0];
return (
<div>
{/* 위 한 줄: 그룹 segment (라벨 + 카운트) */}
<CPSegment
value={filterGroup}
onChange={setFilterGroup}
options={ACTION_GROUPS.map((g) => ({
value: g.id,
label: (
<span style={{ display: "inline-flex", alignItems: "center", gap: 4 }}>
{g.name}
<span
style={{
fontSize: 8.5,
fontWeight: 700,
padding: "0 4px",
background: "rgba(var(--v5-primary-rgb), 0.12)",
color: "var(--v5-primary, #6c5ce7)",
borderRadius: 999,
lineHeight: "12px",
}}
>
{g.items.length}
</span>
</span>
),
}))}
/>
{/* 아래 한 줄(들): 선택 그룹 액션 chip — wrap */}
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: 4,
marginTop: 8,
}}
>
{currentGroup.items.map((key) => {
const def = ACTION_DEFS[key];
if (!def) return null;
return (
<ActionChip
key={key}
active={value === key}
def={def}
onClick={() => onChange(key)}
/>
);
})}
</div>
</div>
);
}
function ActionChip({
active,
def,
onClick,
}: {
active: boolean;
def: ActionDef;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
title={def.desc}
style={{
display: "inline-flex",
alignItems: "center",
gap: 5,
padding: "5px 10px 5px 8px",
background: active
? "rgba(var(--v5-primary-rgb), 0.13)"
: "var(--cp-bg-subtle)",
border: `1px solid ${
active ? "rgba(var(--v5-primary-rgb), 0.5)" : "var(--cp-border-subtle)"
}`,
borderRadius: 4,
cursor: "pointer",
fontFamily: "var(--v5-font-sans)",
color: active ? "var(--v5-primary, #6c5ce7)" : "var(--cp-text-sec)",
fontSize: 11,
fontWeight: active ? 700 : 500,
letterSpacing: "-0.005em",
boxShadow: active
? "0 0 0 2px rgba(var(--v5-primary-rgb), 0.06)"
: "none",
transition: "background .14s ease, color .14s ease",
}}
onMouseEnter={(e) => {
if (!active) {
(e.currentTarget as HTMLButtonElement).style.background =
"var(--cp-surface-hover, var(--cp-surface))";
}
}}
onMouseLeave={(e) => {
if (!active) {
(e.currentTarget as HTMLButtonElement).style.background =
"var(--cp-bg-subtle)";
}
}}
>
<span
aria-hidden
style={{
color: active ? def.color : "var(--cp-text-muted)",
display: "inline-flex",
alignItems: "center",
}}
>
{def.miniIcon}
</span>
<span>{def.label}</span>
</button>
);
}
export default InvButtonConfigPanel;