a8ded6455d
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>
430 lines
15 KiB
TypeScript
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;
|