1aa48cc0bb
## 디자인 개편 - IDE 톤 CSS 오버라이드 (builder-ide.css) - 컴팩트화 (폰트/간격/패딩 축소) - INVYONE STUDIO 로고 추가 (SlimToolbar) - 좌측 수평 탭 → 수직 아코디언 (details/summary) - 우측 속성 패널 신설 (V2PropertiesPanel 완전 이주) - 다크모드 지원 (7개 통합 컴포넌트 inline hex → CSS 변수) ## 기반 시스템 - ScreenDefinition.fields/connections 타입 확장 - ComponentDefinition.dataPorts 타입 확장 - FieldConfig adapters (fieldsToColumns/Search/Form) - DataPortBus + setupConnections runtime - FieldsPanel (화면 수준 필드 관리 패널) ## 컴포넌트 통합 (Phase A~C) - divider (3→1): 가로/세로 + 텍스트 구분선 - title (2→1): h1~h6/body/caption variant - button (3→1): 6 variant × 13 actionType - search (3→1): inline/stacked 검색 필터 - input (20+→1): FieldConfig.type 10종 내부 분기 - stats (6→1): card/chip/bigNumber 3종 스타일 - table (9→1): table/split/grouped/pivot/card 5종 displayMode - container (11→1): tabs/section/accordion/repeater/conditional 5종 ## 버그 수정 (기존 VEX 코드) - 드래그 드롭 불가 (defaultSize camelCase 불일치) - 설정 변경 미반영 (componentConfig vs component_config) - ConfigPanel 미인식 (config_panel vs configPanel) - v2- 자동 매핑 함정 (INVYONE_UNIFIED_IDS 화이트리스트) - LayerManagerPanel 무한 API 호출 (useEffect deps) - Button size 이름 충돌 (visual size 객체 vs config string) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
222 lines
7.8 KiB
TypeScript
222 lines
7.8 KiB
TypeScript
"use client";
|
|
|
|
import React from "react";
|
|
import type { StatsConfig, StatsItem } from "./types";
|
|
|
|
/**
|
|
* Stats ConfigPanel — 통계 카드 설정 편집.
|
|
*
|
|
* items 목록 편집 + 배치/스타일/그리드 열 수. Phase B-2 최소 구현.
|
|
* Phase F 에서 아이콘 선택기, column 드롭다운 (DB 메타 연동) 확장.
|
|
*/
|
|
|
|
export interface StatsConfigPanelProps {
|
|
config?: StatsConfig;
|
|
onChange?: (config: StatsConfig) => void;
|
|
selectedComponent?: { id: string; config?: StatsConfig; [k: string]: any };
|
|
}
|
|
|
|
export const StatsConfigPanel: React.FC<StatsConfigPanelProps> = ({
|
|
config,
|
|
onChange,
|
|
selectedComponent,
|
|
}) => {
|
|
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) });
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-col gap-3 p-3 text-xs">
|
|
<div>
|
|
<label className="text-muted-foreground mb-1 block text-[0.62rem] font-semibold tracking-wider uppercase">
|
|
제목 (선택)
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={current.title || ""}
|
|
onChange={(e) => patch({ title: e.target.value || undefined })}
|
|
placeholder="예: 이번 달 KPI"
|
|
className="border-border bg-background w-full rounded border px-2 py-1 text-xs"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="text-muted-foreground mb-1 block text-[0.62rem] font-semibold tracking-wider uppercase">
|
|
배치
|
|
</label>
|
|
<select
|
|
value={current.orientation || "horizontal"}
|
|
onChange={(e) =>
|
|
patch({ orientation: e.target.value as StatsConfig["orientation"] })
|
|
}
|
|
className="border-border bg-background w-full rounded border px-2 py-1 text-xs"
|
|
>
|
|
<option value="horizontal">가로</option>
|
|
<option value="vertical">세로</option>
|
|
<option value="grid">그리드</option>
|
|
</select>
|
|
</div>
|
|
|
|
{current.orientation === "grid" && (
|
|
<div>
|
|
<label className="text-muted-foreground mb-1 block text-[0.62rem] font-semibold tracking-wider uppercase">
|
|
그리드 열 수
|
|
</label>
|
|
<input
|
|
type="number"
|
|
value={current.columns ?? 4}
|
|
onChange={(e) => patch({ columns: Number(e.target.value) })}
|
|
min={1}
|
|
max={8}
|
|
className="border-border bg-background w-full rounded border px-2 py-1 text-xs"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div>
|
|
<label className="text-muted-foreground mb-1 block text-[0.62rem] font-semibold tracking-wider uppercase">
|
|
표시 스타일
|
|
</label>
|
|
<select
|
|
value={current.style || "card"}
|
|
onChange={(e) => patch({ style: e.target.value as StatsConfig["style"] })}
|
|
className="border-border bg-background w-full rounded border px-2 py-1 text-xs"
|
|
>
|
|
<option value="card">카드</option>
|
|
<option value="chip">칩 (pill)</option>
|
|
<option value="bigNumber">큰 숫자</option>
|
|
</select>
|
|
</div>
|
|
|
|
{/* ─── 항목 목록 ─── */}
|
|
<div className="border-border mt-2 border-t pt-2">
|
|
<div className="mb-2 flex items-center justify-between">
|
|
<span className="text-muted-foreground text-[0.62rem] font-semibold tracking-wider uppercase">
|
|
항목 ({items.length})
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={addItem}
|
|
className="border-border hover:bg-accent rounded border px-2 py-0.5 text-[0.65rem]"
|
|
>
|
|
+ 추가
|
|
</button>
|
|
</div>
|
|
|
|
{items.length === 0 && (
|
|
<div className="text-muted-foreground border-border rounded border border-dashed p-2 text-center text-[0.6rem]">
|
|
항목이 없습니다. “+추가” 를 눌러주세요.
|
|
</div>
|
|
)}
|
|
|
|
{items.map((item, idx) => (
|
|
<div
|
|
key={idx}
|
|
className="border-border mb-2 rounded border p-2"
|
|
>
|
|
<div className="mb-1 flex items-center justify-between">
|
|
<span className="text-muted-foreground text-[0.55rem] font-semibold">
|
|
#{idx + 1}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => removeItem(idx)}
|
|
className="text-destructive text-[0.6rem] hover:underline"
|
|
>
|
|
삭제
|
|
</button>
|
|
</div>
|
|
<div className="flex flex-col gap-1.5">
|
|
<input
|
|
type="text"
|
|
value={item.label}
|
|
onChange={(e) => updateItem(idx, { label: e.target.value })}
|
|
placeholder="라벨"
|
|
className="border-border bg-background w-full rounded border px-1.5 py-0.5 text-[0.65rem]"
|
|
/>
|
|
<input
|
|
type="text"
|
|
value={item.value?.toString() ?? ""}
|
|
onChange={(e) =>
|
|
updateItem(idx, {
|
|
value: isNaN(Number(e.target.value))
|
|
? e.target.value
|
|
: Number(e.target.value),
|
|
})
|
|
}
|
|
placeholder="값 (숫자 또는 문자)"
|
|
className="border-border bg-background w-full rounded border px-1.5 py-0.5 text-[0.65rem]"
|
|
/>
|
|
<div className="grid grid-cols-2 gap-1.5">
|
|
<input
|
|
type="text"
|
|
value={item.icon || ""}
|
|
onChange={(e) => updateItem(idx, { icon: e.target.value || undefined })}
|
|
placeholder="아이콘 (예: 💰)"
|
|
className="border-border bg-background w-full rounded border px-1.5 py-0.5 text-[0.65rem]"
|
|
/>
|
|
<input
|
|
type="color"
|
|
value={item.color || "#3b82f6"}
|
|
onChange={(e) => updateItem(idx, { color: e.target.value })}
|
|
className="border-border bg-background h-6 w-full rounded border"
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-1.5">
|
|
<input
|
|
type="text"
|
|
value={item.delta || ""}
|
|
onChange={(e) => updateItem(idx, { delta: e.target.value || undefined })}
|
|
placeholder="변화 (예: +12%)"
|
|
className="border-border bg-background w-full rounded border px-1.5 py-0.5 text-[0.65rem]"
|
|
/>
|
|
<select
|
|
value={item.deltaDirection || "neutral"}
|
|
onChange={(e) =>
|
|
updateItem(idx, {
|
|
deltaDirection: e.target.value as StatsItem["deltaDirection"],
|
|
})
|
|
}
|
|
className="border-border bg-background w-full rounded border px-1.5 py-0.5 text-[0.65rem]"
|
|
>
|
|
<option value="neutral">·</option>
|
|
<option value="up">▲ 상승</option>
|
|
<option value="down">▼ 하락</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default StatsConfigPanel;
|