INVYONE 화면 디자이너 개편 + 컴포넌트 86→8 통합

## 디자인 개편
- 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>
This commit is contained in:
2026-04-12 20:37:23 +09:00
parent a0c9d9a0ab
commit 1aa48cc0bb
68 changed files with 7800 additions and 46 deletions
+3 -1
View File
@@ -78,8 +78,10 @@ function BuilderInner() {
// AppLayout 의 헤더/탭 아래 영역에만 배치. fixed 덮어쓰기 대신 일반 flow 로
// 헤더와 안 겹치게.
// ★ ide-builder 클래스: builder-ide.css 의 IDE 톤(HSL 오버라이드)을 이 스코프
// 안에서만 먹게 한다. ScreenDesigner 의 JSX 는 건드리지 않고 색만 바꿈.
return (
<div className="h-[calc(100vh-4rem)] w-full overflow-hidden bg-background">
<div className="ide-builder h-[calc(100vh-4rem)] w-full overflow-hidden bg-background">
<ScreenDesigner
selectedScreen={selectedScreen}
onBackToList={() => router.push("/admin/screenMng/screenMngList")}
+3
View File
@@ -8,6 +8,9 @@
/* ===== V5 Cosmic Layout System ===== */
@import "../styles/v5-layout.css";
/* ===== Builder IDE Theme (ScreenDesigner 스코프 오버라이드) ===== */
@import "../styles/builder-ide.css";
/* ===== Dark Mode Variant ===== */
@custom-variant dark (&:is(.dark *));
@@ -95,10 +95,22 @@ export const LayerManagerPanel: React.FC<LayerManagerPanelProps> = ({
}
}, [screenId, components, activeLayerId]);
// ★ 2026-04-11 버그 픽스:
// 기존: deps 가 [loadLayers, loadBaseLayerComponents]. loadBaseLayerComponents
// 의 deps 에 components 가 있어서 설정 변경 시마다 useCallback 재생성 →
// useEffect 재실행 → loadLayers() 반복 호출 → 백엔드 500 에러 폭주.
// Fix: 실제로 필요한 primitive deps (screenId, activeLayerId) 만 사용.
useEffect(() => {
if (!screenId) return;
loadLayers();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [screenId]);
useEffect(() => {
if (!screenId) return;
loadBaseLayerComponents();
}, [loadLayers, loadBaseLayerComponents]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [screenId, activeLayerId]);
// Zone별 레이어 그룹핑
const getLayersForZone = useCallback((zoneId: number): DBLayer[] => {
+105 -25
View File
@@ -3,7 +3,9 @@
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { Database, Cog } from "lucide-react";
import { cn } from "@/lib/utils";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
// 좌측 패널의 수평 탭 → 수직 <details> 아코디언으로 전환 (2026-04-11)
// shadcn Tabs 사용 없음. 필요 시 아래 import 재활성화.
// import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import {
ScreenDefinition,
@@ -122,6 +124,7 @@ import { TableOptionsProvider } from "@/contexts/TableOptionsContext";
// 새로운 통합 UI 컴포넌트
import { SlimToolbar } from "./toolbar/SlimToolbar";
import { V2PropertiesPanel } from "./panels/V2PropertiesPanel";
import { FieldsPanel } from "./panels/FieldsPanel";
// 컴포넌트 초기화 (새 시스템)
import "@/lib/registry/components";
@@ -526,7 +529,8 @@ export default function ScreenDesigner({
}, []);
// 🆕 좌측 패널 탭 상태 관리
const [leftPanelTab, setLeftPanelTab] = useState<string>("components");
// 좌측 패널을 수직 아코디언(<details>)으로 전환한 후로 탭 선택 state 불필요.
// 각 <details> 가 자체적으로 open/close 상태를 관리함.
// 🆕 조건부 영역(Zone) 목록 (DB screen_conditional_zones 기반)
const [zones, setZones] = useState<import("@/types/screen-management").ConditionalZone[]>([]);
@@ -6391,7 +6395,7 @@ export default function ScreenDesigner({
<div className="flex flex-1 overflow-hidden">
{/* 통합 패널 - 좌측 사이드바 제거 후 너비 300px로 확장 */}
{panelStates.v2?.isOpen && (
<div className="border-border bg-card flex h-full w-[300px] flex-col overflow-hidden border-r shadow-sm">
<div className="border-border bg-card flex h-full w-[200px] flex-col overflow-hidden border-r shadow-sm">
<div className="border-border flex shrink-0 items-center justify-between border-b px-4 py-3">
<h3 className="text-foreground text-sm font-semibold"></h3>
<button
@@ -6402,20 +6406,11 @@ export default function ScreenDesigner({
</button>
</div>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<Tabs value={leftPanelTab} onValueChange={setLeftPanelTab} className="flex min-h-0 flex-1 flex-col">
<TabsList className="mx-4 mt-2 grid h-8 w-auto grid-cols-3 gap-1">
<TabsTrigger value="components" className="text-xs">
</TabsTrigger>
<TabsTrigger value="layers" className="text-xs">
</TabsTrigger>
<TabsTrigger value="properties" className="text-xs">
</TabsTrigger>
</TabsList>
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
<TabsContent value="components" className="mt-0 flex-1 overflow-hidden">
<details open className="ide-section">
<summary className="ide-section-header"></summary>
<div className="ide-section-body">
<ComponentsPanel
tables={filteredTables}
searchTerm={searchTerm}
@@ -6433,11 +6428,28 @@ export default function ScreenDesigner({
onTableSelect={handleTableSelect}
showTableSelector={true}
/>
</TabsContent>
</div>
</details>
{/* 🆕 레이어 관리 탭 (DB 기반) */}
<TabsContent value="layers" className="mt-0 flex-1 overflow-hidden">
<LayerManagerPanel
{/* INVYONE 필드 섹션 (Phase 1+) — FieldConfig[] 편집 */}
<details className="ide-section">
<summary className="ide-section-header"></summary>
<div className="ide-section-body">
<FieldsPanel
tableName={selectedScreen?.table_name ?? null}
fields={selectedScreen?.fields ?? []}
onFieldsChange={(next) =>
onScreenUpdate?.({ fields: next } as any)
}
/>
</div>
</details>
{/* 레이어 관리 섹션 (DB 기반) */}
<details className="ide-section">
<summary className="ide-section-header"></summary>
<div className="ide-section-body">
<LayerManagerPanel
screenId={selectedScreen?.screen_id || null}
activeLayerId={Number(activeLayerIdRef.current) || 1}
onLayerChange={async (layerId) => {
@@ -6472,10 +6484,19 @@ export default function ScreenDesigner({
zones={zones}
onZonesChange={setZones}
/>
</TabsContent>
</div>
</details>
<TabsContent value="properties" className="mt-0 flex-1 overflow-hidden">
{/* 탭 내부 컴포넌트 선택 시에도 V2PropertiesPanel 사용 */}
{/* (2026-04-11, )
* 500 {false && (...)}
* . V2PropertiesPanel
* . .
*/}
{false && (
<details className="ide-section">
<summary className="ide-section-header"></summary>
<div className="ide-section-body">
{/* 섹션 내부 컴포넌트 선택 시에도 V2PropertiesPanel 사용 */}
{selectedTabComponentInfo ? (
(() => {
const tabComp = selectedTabComponentInfo.component;
@@ -6984,8 +7005,10 @@ export default function ScreenDesigner({
menuObjid={menuObjid}
/>
)}
</TabsContent>
</Tabs>
</div>
</details>
)}
</div>
</div>
</div>
)}
@@ -7902,6 +7925,63 @@ export default function ScreenDesigner({
); /* 🔥 줌 래퍼 닫기 */
})()}
</div>
{/* (2026-04-11, 3 )
* - "편집" V2PropertiesPanel .
* - selectedComponent V2PropertiesPanel ,
* "블록을 선택하세요" .
* - "편집" Edit (500 ).
*/}
{panelStates.v2?.isOpen && (
<aside className="border-border bg-card flex h-full w-[280px] flex-col overflow-hidden border-l shadow-sm">
<div className="border-border flex shrink-0 items-center justify-between border-b px-3 py-2">
<h3 className="text-foreground text-xs font-bold tracking-wider uppercase">
</h3>
<span className="text-muted-foreground text-[0.55rem]">
{groupState.selectedComponents.length > 0
? `${groupState.selectedComponents.length} selected`
: "Inspector"}
</span>
</div>
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
{selectedComponent ? (
<V2PropertiesPanel
selectedComponent={selectedComponent || undefined}
tables={tables}
onUpdateProperty={updateComponentProperty}
onDeleteComponent={deleteComponent}
onCopyComponent={copyComponent}
currentTable={tables.length > 0 ? tables[0] : undefined}
currentTableName={selectedScreen?.table_name}
currentScreenCompanyCode={selectedScreen?.company_code}
dragState={dragState}
onStyleChange={(style) => {
if (selectedComponent) {
updateComponentProperty(
selectedComponent.id,
"style",
style,
);
}
}}
allComponents={[
...layout.components,
...otherLayerComponents,
]}
menuObjid={menuObjid}
/>
) : (
<div className="text-muted-foreground flex flex-1 items-center justify-center px-4 text-center text-xs leading-relaxed">
<br />
</div>
)}
</div>
</aside>
)}
</div>{" "}
{/* 메인 컨테이너 닫기 */}
{/* 🆕 플로우 버튼 그룹 생성 다이얼로그 */}
@@ -152,7 +152,61 @@ export function ComponentsPanel({
"table-list", // → v2-table-list
"text-display", // → v2-text-display
"divider-line", // → v2-divider-line
"numbering-rule", // → v2-numbering-rule
// ★ 2026-04-11 통합 컴포넌트(Phase A-1): 구분선 3종 → `divider`
"v2-divider-line", // → divider
"v2-split-line", // → divider (drag-resize 기능은 다음 Phase)
// ★ 2026-04-11 통합 컴포넌트(Phase A-2): 제목/텍스트 2종 → `title`
"v2-text-display", // → title
// text-display 는 아래 기존 항목에서 이미 숨김 처리됨
// ★ 2026-04-11 통합 컴포넌트(Phase A-3): 버튼 → `button`
"v2-button-primary", // → button
// button-primary 는 아래 기존 항목에서 이미 숨김 처리됨
// related-data-buttons 는 기존에 이미 숨김
// ★ 2026-04-11 통합 컴포넌트(Phase A-4): 검색 필터 → `search`
"v2-table-search-widget", // → search
// table-search-widget, autocomplete-search-input 은 기존에 이미 숨김
// ★ 2026-04-11 통합 컴포넌트(Phase B-1): 필드 입력 20+종 → `input`
"v2-input", // → input (type='text'/'number')
"v2-select", // → input (type='select')
"v2-date", // → input (type='date')
"v2-category-manager", // → input (type='select', 추후 category 특화)
"v2-file-upload", // → input (type='file')
"v2-media", // → input (type='file')
"v2-numbering-rule", // → input (type='code')
"v2-location-swap-selector", // → input (type='entity')
// 아래 legacy 들은 이미 상단 "기본 입력 컴포넌트" 섹션에서 hidden:
// text-input, number-input, date-input, textarea-basic, image-widget,
// entity-search-input, autocomplete-search-input, file-upload (일부)
// 이미 리스트에 없는 것만 추가:
"select-basic", // → input (type='select')
"checkbox-basic", // → input (type='checkbox')
"radio-basic", // → input (type='select', radio 렌더)
"toggle-switch", // → input (type='checkbox', toggle 렌더)
"slider-basic", // → input (type='number', slider 렌더)
// ★ 2026-04-11 통합 컴포넌트(Phase B-2): 통계/KPI → `stats`
"v2-aggregation-widget", // → stats
"v2-status-count", // → stats
"v2-card-display", // → stats
// aggregation-widget, card-display 는 기존 상단에서 이미 숨김
// form 컴포넌트는 롤백됨 (2026-04-11): 3뷰 탭 구조로 처리 예정.
"field-example-1", // legacy form-layout 의 실제 id (숨김 유지)
// ★ 2026-04-11 통합 컴포넌트(Phase C-1): 데이터 테이블 → `table`
"v2-table-list", // → table (displayMode='table')
"v2-table-grouped", // → table (displayMode='grouped')
"v2-pivot-grid", // → table (displayMode='pivot')
"v2-split-panel-layout", // → table (displayMode='split')
// table-list, split-panel-layout, split-panel-layout2, modal-repeater-table,
// simple-repeater-table, tax-invoice-list, pivot-grid 는 기존 상단에서 이미 숨김
// ★ 2026-04-11 통합 컴포넌트(Phase C-2): 컨테이너 → `container`
"v2-tabs-widget", // → container (containerType='tabs')
"v2-section-card", // → container (containerType='section', sectionVariant='card')
"v2-section-paper", // → container (containerType='section', sectionVariant='paper')
"v2-repeat-container", // → container (containerType='repeater')
"v2-repeater", // → container (containerType='repeater')
// accordion-basic, conditional-container, section-card, section-paper,
// tabs, repeat-container, repeat-screen-modal, repeater-field-group,
// screen-split-panel 는 기존 상단에서 이미 숨김
"numbering-rule",
"section-paper", // → v2-section-paper
"section-card", // → v2-section-card
"location-swap-selector", // → v2-location-swap-selector
@@ -218,10 +272,24 @@ export function ComponentsPanel({
};
// 드래그 시작 핸들러
//
// ★ 2026-04-11 버그 픽스:
// handleComponentDrop (ScreenDesigner.tsx) 이 component.defaultSize,
// component.defaultConfig, component.webType (camelCase) 로 접근하지만
// ComponentDefinition 은 default_size / default_config / web_type
// (snake_case) 로 저장한다. 격자 스냅이 켜진 상태에서 이 불일치 때문에
// drop 함수가 TypeError 로 중단되어 컴포넌트 배치가 전혀 안 됐음.
// 여기서 camelCase 별칭을 함께 주입해서 호환.
const handleDragStart = (e: React.DragEvent<HTMLDivElement>, component: ComponentDefinition) => {
const dragData = {
type: "component",
component: component,
component: {
...component,
// snake_case → camelCase 별칭 (handleComponentDrop 호환)
defaultSize: component.default_size,
defaultConfig: component.default_config,
webType: component.web_type,
},
};
e.dataTransfer.setData("application/json", JSON.stringify(dragData));
e.dataTransfer.effectAllowed = "copy";
@@ -0,0 +1,266 @@
"use client";
/**
* FieldsPanel — 화면 수준 FieldConfig[] 편집 패널
*
* ScreenDesigner 의 좌측 통합 패널에 "필드" 탭으로 들어간다. 현재 선택된 화면의
* primary table 을 기준으로 백엔드 메타 API (/api/meta/tables/{name}/fields) 를
* 호출해 FieldConfig[] 를 받아오고, 필드별 visible / searchable 등을 편집할 수
* 있다. 편집 결과는 onFieldsChange 로 상위에 올라가 ScreenDefinition.fields 에
* 저장된다.
*
* 역할:
* - 테이블 메타 읽기 (Phase 1 API 활용)
* - 필드 목록 표시 (PK / 필수 / 시스템 필드 구분 배지)
* - 필드별 visible / searchable 토글
* - 순서 재정렬 (위/아래 버튼)
*
* 범위 밖:
* - 고급 편집(label, options, ref, computed 등)은 추후 확장
* - 필드 추가/삭제는 DB 메타 소스 기준이므로 지원 안 함
*/
import React, { useEffect, useMemo, useState } from "react";
import { getMetaFields } from "@/lib/api/meta";
import type { FieldConfig } from "@/types/invyone-component";
interface FieldsPanelProps {
/** 현재 선택된 화면의 primary table 이름 */
tableName: string | null | undefined;
/** 현재 ScreenDefinition.fields 값 */
fields?: FieldConfig[];
/** 편집 결과를 상위에 반영하는 콜백 */
onFieldsChange: (next: FieldConfig[]) => void;
}
export function FieldsPanel({
tableName,
fields,
onFieldsChange,
}: FieldsPanelProps) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// 현재 편집 중인 필드 목록. prop 이 바뀌면 재동기화.
const localFields = useMemo<FieldConfig[]>(() => fields ?? [], [fields]);
// 테이블이 바뀌고 fields 가 비어 있으면 자동 로드 (편의성)
useEffect(() => {
if (!tableName) return;
if (localFields.length > 0) return;
void handleLoad();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tableName]);
const handleLoad = async () => {
if (!tableName) {
setError("테이블이 선택되지 않았습니다");
return;
}
setLoading(true);
setError(null);
try {
const data = await getMetaFields(tableName);
const loaded = (data?.fields ?? []) as FieldConfig[];
onFieldsChange(loaded);
} catch (err: any) {
setError(err?.message ?? "필드 로드 실패");
} finally {
setLoading(false);
}
};
const patch = (column: string, patchObj: Partial<FieldConfig>) => {
const next = localFields.map((f) =>
f.column === column ? { ...f, ...patchObj } : f,
);
onFieldsChange(next);
};
const toggleVisible = (column: string) => {
const target = localFields.find((f) => f.column === column);
if (!target) return;
patch(column, { visible: !(target.visible !== false) });
};
const toggleSearchable = (column: string) => {
const target = localFields.find((f) => f.column === column);
if (!target) return;
patch(column, { searchable: !target.searchable });
};
const move = (column: string, delta: -1 | 1) => {
const idx = localFields.findIndex((f) => f.column === column);
if (idx < 0) return;
const target = idx + delta;
if (target < 0 || target >= localFields.length) return;
const next = [...localFields];
[next[idx], next[target]] = [next[target], next[idx]];
// order 재부여
next.forEach((f, i) => {
f.order = i + 1;
});
onFieldsChange(next);
};
const visibleCount = localFields.filter((f) => f.visible !== false).length;
const searchableCount = localFields.filter((f) => f.searchable).length;
return (
<div className="flex h-full flex-col overflow-hidden">
{/* 헤더 — 테이블명 + 로드 버튼 */}
<div className="border-border flex items-center gap-2 border-b px-3 py-2">
<div className="text-muted-foreground min-w-0 flex-1 truncate font-mono text-xs">
{tableName ? tableName : <span className="italic"> </span>}
</div>
<button
type="button"
onClick={handleLoad}
disabled={!tableName || loading}
className="border-border text-foreground hover:bg-accent disabled:opacity-40 rounded border px-2 py-0.5 text-xs transition-colors"
>
{loading ? "로딩…" : "메타에서 로드"}
</button>
</div>
{/* 에러 */}
{error && (
<div className="border-destructive/40 bg-destructive/10 text-destructive border-b px-3 py-1.5 text-xs">
{error}
</div>
)}
{/* 필드 목록 */}
{localFields.length === 0 ? (
<div className="text-muted-foreground flex flex-1 items-center justify-center px-4 text-center text-xs leading-relaxed">
{tableName ? (
<>
.
<br />
&ldquo; &rdquo; .
</>
) : (
<>
<br />
.
</>
)}
</div>
) : (
<div className="flex-1 overflow-y-auto">
{localFields.map((f, idx) => (
<div
key={f.column}
className="border-border hover:bg-accent/50 group flex items-center gap-1.5 border-b px-2 py-1.5"
>
{/* visible 체크 */}
<button
type="button"
onClick={() => toggleVisible(f.column)}
className={`flex size-4 shrink-0 items-center justify-center rounded border text-[0.55rem] ${
f.visible !== false
? "bg-primary text-primary-foreground border-primary"
: "border-border text-transparent"
}`}
title={f.visible !== false ? "숨기기" : "표시"}
>
</button>
{/* 라벨 + 컬럼명 */}
<div className="min-w-0 flex-1">
<div className="text-foreground truncate text-xs font-medium">
{f.label}
</div>
<div className="text-muted-foreground truncate font-mono text-[0.55rem]">
{f.column}
</div>
</div>
{/* 타입 배지 */}
<span className="bg-muted text-muted-foreground shrink-0 rounded px-1 py-0.5 text-[0.55rem]">
{f.type}
</span>
{/* PK / 필수 / 시스템 배지 */}
{f.pk && (
<span
className="bg-primary text-primary-foreground shrink-0 rounded px-1 py-0.5 text-[0.55rem] font-bold"
title="Primary Key"
>
PK
</span>
)}
{f.required && !f.pk && (
<span
className="bg-destructive text-destructive-foreground shrink-0 rounded px-1 py-0.5 text-[0.55rem] font-bold"
title="필수"
>
*
</span>
)}
{f.system && (
<span
className="text-muted-foreground bg-muted shrink-0 rounded px-1 py-0.5 text-[0.55rem]"
title="시스템 필드"
>
sys
</span>
)}
{/* searchable 토글 */}
<button
type="button"
onClick={() => toggleSearchable(f.column)}
className={`shrink-0 rounded px-1 py-0.5 text-[0.55rem] font-bold transition-colors ${
f.searchable
? "bg-primary/20 text-primary"
: "text-muted-foreground/50 hover:text-muted-foreground"
}`}
title="검색 대상"
>
🔍
</button>
{/* 순서 이동 */}
<div className="flex shrink-0 flex-col opacity-0 transition-opacity group-hover:opacity-100">
<button
type="button"
onClick={() => move(f.column, -1)}
disabled={idx === 0}
className="text-muted-foreground hover:text-foreground disabled:opacity-20 text-[0.5rem] leading-none"
title="위로"
>
</button>
<button
type="button"
onClick={() => move(f.column, 1)}
disabled={idx === localFields.length - 1}
className="text-muted-foreground hover:text-foreground disabled:opacity-20 text-[0.5rem] leading-none"
title="아래로"
>
</button>
</div>
</div>
))}
</div>
)}
{/* 상태바 */}
<div className="border-border text-muted-foreground flex items-center justify-between border-t px-3 py-1.5 text-[0.55rem]">
<span>
<b className="text-foreground">{localFields.length}</b>
</span>
<span>
<b className="text-foreground">{visibleCount}</b> · {" "}
<b className="text-foreground">{searchableCount}</b>
</span>
</div>
</div>
);
}
export default FieldsPanel;
@@ -268,8 +268,12 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
if (componentId) {
const definition = ComponentRegistry.getComponent(componentId);
if (definition?.configPanel) {
const ConfigPanelComponent = definition.configPanel;
// ★ 2026-04-11: ComponentDefinition 은 config_panel (snake_case) 로 저장됨.
// 기존 코드는 configPanel (camelCase) 만 찾아서 항상 false. 둘 다 체크.
const configPanelFromDef =
(definition as any)?.configPanel ?? (definition as any)?.config_panel;
if (configPanelFromDef) {
const ConfigPanelComponent = configPanelFromDef;
const currentConfig = selectedComponent.componentConfig || {};
// 🔧 ConfigPanelWrapper를 인라인 함수 대신 직접 JSX 반환 (리마운트 방지)
@@ -813,8 +817,12 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
}
// 🆕 ComponentRegistry에서 전용 ConfigPanel이 있는지 먼저 확인
// ★ 2026-04-11: ComponentDefinition 은 config_panel (snake_case) 로 저장됨.
// 기존 코드는 configPanel (camelCase) 만 찾아서 항상 false. 둘 다 체크.
const definition = ComponentRegistry.getComponent(componentId);
if (definition?.configPanel) {
const configPanelFromDef =
(definition as any)?.configPanel ?? (definition as any)?.config_panel;
if (configPanelFromDef) {
// 전용 ConfigPanel이 있으면 renderComponentConfigPanel 호출
const configPanelContent = renderComponentConfigPanel();
if (configPanelContent) {
@@ -160,8 +160,14 @@ export const SlimToolbar: React.FC<SlimToolbarProps> = ({
return (
<div className="flex h-14 items-center justify-between border-b border-border bg-background px-4 shadow-sm">
{/* 좌측: 네비게이션 + 패널 토글 + 화면 정보 */}
{/* 좌측: 브랜드 로고 + 네비게이션 + 패널 토글 + 화면 정보 */}
<div className="flex items-center space-x-4">
{/* INVYONE 브랜드 로고 (리브랜딩) */}
<div className="ide-brand">
<span className="ide-brand-text">INVYONE</span>
<span className="ide-brand-badge">STUDIO</span>
</div>
<Button variant="ghost" size="sm" onClick={onBack} className="flex items-center space-x-2">
<ArrowLeft className="h-4 w-4" />
<span></span>
+86
View File
@@ -0,0 +1,86 @@
/**
* DataPortBus — INVYONE 컴포넌트 간 DataPort 통신용 경량 pub/sub 버스.
*
* 존재 이유:
* v2-core 의 V2EventBus 는 V2EventName 이 사전 정의된 타입 안전 이벤트 버스라,
* DataPort 처럼 런타임에 "componentId.portName" 으로 동적 채널명을 다루기에
* 적합하지 않다. 그래서 DataPort 전용으로 독립된 경량 버스를 둔다.
*
* 관계:
* v2EventBus — 테이블 refresh 등 사전 정의된 시스템 이벤트
* DataPortBus — 화면 런타임의 DataPort 브리지 (이 파일)
* 둘은 서로 독립. 공존.
*
* 채널 규약:
* channel = `${componentId}.${portName}`
* 예: "cmp_table_1.selectedRow", "cmp_search_1.searchParams"
*/
type Handler = (value: unknown) => void;
/**
* 화면(ScreenDesigner 인스턴스) 단위로 독립적인 DataPortBus.
*
* 같은 사용자가 여러 화면을 동시에 띄워도 서로 간섭하지 않도록 화면 마운트 시
* new 로 만들고 언마운트 시 destroy 한다.
*/
export class DataPortBus {
private channels = new Map<string, Set<Handler>>();
/** componentId + portName → 내부 채널 문자열 */
static channelOf(componentId: string, portName: string): string {
return `${componentId}.${portName}`;
}
/**
* 구독 등록. 반환값은 cleanup 함수 (호출 시 해당 구독만 해제).
*/
subscribe(channel: string, handler: Handler): () => void {
if (!this.channels.has(channel)) {
this.channels.set(channel, new Set());
}
this.channels.get(channel)!.add(handler);
return () => {
this.channels.get(channel)?.delete(handler);
};
}
/**
* 값 발행. 해당 채널에 구독 중인 모든 핸들러를 동기 호출한다.
* 하나의 핸들러가 throw 해도 나머지는 계속 실행한다.
*/
publish(channel: string, value: unknown): void {
const handlers = this.channels.get(channel);
if (!handlers || handlers.size === 0) return;
handlers.forEach((h) => {
try {
h(value);
} catch (err) {
console.error(`[DataPortBus] handler error on "${channel}":`, err);
}
});
}
/** 모든 구독 제거. 화면 언마운트 시 호출. */
destroy(): void {
this.channels.clear();
}
/** 디버깅용: 현재 활성 채널 목록 */
getChannels(): string[] {
return Array.from(this.channels.keys());
}
/** 디버깅용: 특정 채널의 구독자 수 */
getSubscriberCount(channel: string): number {
return this.channels.get(channel)?.size ?? 0;
}
}
/**
* 단일 화면만 쓰는 경우를 위한 전역 기본 인스턴스.
*
* 여러 화면을 동시에 띄우는 시나리오에서는 화면마다 new DataPortBus() 를 만들어
* React Context 로 내려주는 게 권장.
*/
export const defaultDataPortBus = new DataPortBus();
+58
View File
@@ -0,0 +1,58 @@
/**
* DataPort Runtime — Connection 배열을 DataPortBus 브리지로 풀어주는 함수.
*
* 사용 시점:
* 화면 렌더러가 Screen(혹은 Template) 을 마운트할 때 setupConnections 를
* 호출하고, 반환된 cleanup 함수를 언마운트 시 호출한다.
*
* 동작:
* 각 Connection 에 대해
* 1. from 컴포넌트의 output 채널을 구독
* 2. 값이 들어오면 to 컴포넌트의 input 채널로 publish
* 즉 화면 수준 Connection 선언을 런타임 브리지로 풀어준다.
*
* 컴포넌트 쪽 책임:
* - 각 v2-* 컴포넌트는 자기 outputs 포트에 값이 생기면 DataPortBus 에 publish
* - inputs 포트는 subscribe 해서 값이 오면 처리
* 이 파일은 그 둘을 중간에서 연결만 한다.
*/
import type { Connection } from "@/types/invyone-component";
import { DataPortBus } from "./DataPortBus";
/**
* Connection[] 을 DataPortBus 브리지로 변환.
*
* @param connections 화면/템플릿에 선언된 포트 연결 목록
* @param bus 연결을 걸 대상 DataPortBus 인스턴스
* @returns cleanup 함수. 화면 언마운트 시 반드시 호출 (메모리 누수 방지).
*/
export function setupConnections(
connections: Connection[] | undefined,
bus: DataPortBus,
): () => void {
if (!connections || connections.length === 0) {
return () => {};
}
const unsubs: Array<() => void> = [];
for (const conn of connections) {
const fromChannel = DataPortBus.channelOf(
conn.from.componentId,
conn.from.port,
);
const toChannel = DataPortBus.channelOf(
conn.to.componentId,
conn.to.port,
);
const unsub = bus.subscribe(fromChannel, (value) => {
bus.publish(toChannel, value);
});
unsubs.push(unsub);
}
return () => {
unsubs.forEach((u) => u());
};
}
+177
View File
@@ -0,0 +1,177 @@
/**
* FieldConfig Adapter — INVYONE FieldConfig[] 를 기존 v2-* 컴포넌트 내부 포맷
* (ColumnConfig / SearchField / FormField) 으로 변환한다.
*
* 역할:
* 화면 디자이너에서 Screen 수준으로 정의한 fields 배열을 각 컴포넌트가
* 소비할 수 있는 기존 포맷으로 풀어주는 브리지. 컴포넌트 자체를 FieldConfig
* 네이티브로 리팩토링하기 전까지의 호환 레이어.
*
* 원칙:
* - 호환성 우선. 기존 포맷에 없는 필드는 버리고 기본값은 보존.
* - 타입은 Record<string, any>. v2-* 포맷이 컴포넌트마다 달라 강타입 못 씀.
* - fields 가 없으면 호출부는 fallback 으로 기존 config.columns 사용 (이 파일
* 호출 자체를 건너뛰어야 함).
*/
import type { FieldConfig } from "@/types/invyone-component";
/**
* FieldConfig[] → v2-table-list 의 ColumnConfig[] 호환 배열.
*
* 상세 매핑 규칙은 v2-table-list 내부 포맷 확정 후 보강한다. 현재는 공통 필드
* (column_name / column_label / visible / display_order / width / align / sortable)
* 만 매핑.
*/
export function fieldsToColumns(
fields: FieldConfig[],
): Record<string, any>[] {
return [...fields]
.filter((f) => f.visible !== false && !f.system)
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
.map((f) => ({
column_name: f.column,
column_label: f.label,
label: f.label,
visible: f.visible !== false,
display_order: f.order ?? 0,
width: f.width,
align: f.align ?? (f.type === "number" ? "right" : "left"),
sortable: f.sortable ?? true,
data_type: f.type,
format: f.format,
pk: f.pk ?? false,
editable: f.editable ?? true,
}));
}
/**
* FieldConfig[] → 검색 위젯의 SearchField[] 호환 배열.
*
* searchable: true 인 것만 포함. 타입별 기본 검색 모드 매핑:
* date/datetime/number → 'range'
* select → 'multi'
* entity → 'single' (팝업)
* code → 'exact'
* text/textarea → 'partial'
* checkbox → 'tri' (전체/✓/✗)
*/
export function fieldsToSearchFields(
fields: FieldConfig[],
): Record<string, any>[] {
return fields
.filter((f) => f.searchable && !f.system)
.map((f) => ({
column_name: f.column,
column_label: f.label,
label: f.label,
data_type: f.type,
search_mode: getDefaultSearchMode(f.type),
options: f.options,
ref: f.ref,
default_value: f.defaultValue,
}));
}
/**
* FieldConfig[] → 폼 컴포넌트의 FormField[] 호환 배열.
*
* system 필드는 자동 제외. required / editable / options / ref / computed 는
* 그대로 전달. pk 이고 code 타입이면 readonly 자동 설정 (자동채번).
*/
export function fieldsToFormFields(
fields: FieldConfig[],
): Record<string, any>[] {
return [...fields]
.filter((f) => f.visible !== false && !f.system)
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
.map((f) => ({
column_name: f.column,
column_label: f.label,
label: f.label,
data_type: f.type,
required: f.required ?? false,
editable: f.editable ?? true,
readonly: f.editable === false || f.type === "code",
default_value: f.defaultValue,
placeholder: f.placeholder,
options: f.options,
ref: f.ref,
format: f.format,
computed: f.computed,
pk: f.pk ?? false,
}));
}
/**
* 역방향: 기존 컬럼 포맷 → FieldConfig[] 추정.
*
* 마이그레이션 도우미. 완벽하지 않음 (검색 모드 역추정 불가, computed 등 누락
* 가능). 화면 디자이너에서 기존 Screen 을 "FieldConfig 로 자동 채우기" 할 때
* 초기값 생성용.
*/
export function columnsToFields(
columns: Record<string, any>[],
): FieldConfig[] {
return columns.map((col, idx) => ({
column: col.column_name ?? col.column ?? `col_${idx}`,
label:
col.column_label ?? col.label ?? col.column_name ?? `컬럼 ${idx + 1}`,
type: normalizeType(col.data_type ?? col.type ?? "text"),
visible: col.visible !== false,
order: col.display_order ?? idx,
required: col.required === true,
editable: col.editable !== false,
width: col.width,
align: col.align,
sortable: col.sortable !== false,
searchable: col.searchable === true,
format: col.format,
options: col.options,
ref: col.ref,
pk: col.pk === true,
system: col.system === true,
}));
}
// ─── 내부 헬퍼 ──────────────────────────────────────────────────────────
type SearchMode = "exact" | "partial" | "range" | "multi" | "single" | "tri";
function getDefaultSearchMode(type: FieldConfig["type"]): SearchMode {
switch (type) {
case "date":
case "datetime":
case "number":
return "range";
case "select":
return "multi";
case "entity":
return "single";
case "code":
return "exact";
case "checkbox":
return "tri";
case "text":
case "textarea":
default:
return "partial";
}
}
function normalizeType(raw: string): FieldConfig["type"] {
const allowed: FieldConfig["type"][] = [
"text",
"number",
"date",
"datetime",
"select",
"entity",
"checkbox",
"textarea",
"file",
"code",
];
return (allowed as string[]).includes(raw)
? (raw as FieldConfig["type"])
: "text";
}
@@ -348,9 +348,27 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
(component as any).componentType || component.type || extractTypeFromUrl((component as any).url);
// 레거시 타입을 v2 컴포넌트로 매핑 (v2 컴포넌트가 없으면 원본 유지)
// ★ 2026-04-11: INVYONE 통합 컴포넌트(Phase A~) 는 v2- 매핑에서 제외.
// 예: 'input' 을 'v2-input' 으로 리다이렉트하면 새 통합 Input 대신 기존
// v2-input 이 렌더되어 설정 변경이 안 반영됨.
const INVYONE_UNIFIED_IDS = new Set([
"divider",
"title",
"button",
"search",
"input",
"stats",
// "form" 롤백됨 — 3뷰 탭 구조로 처리 예정
"table",
"container",
]);
const mapToV2ComponentType = (type: string | undefined): string | undefined => {
if (!type) return type;
// INVYONE 통합 컴포넌트는 매핑 없이 그대로 반환
if (INVYONE_UNIFIED_IDS.has(type)) return type;
// 이미 v2- 접두사가 있으면 그대로 반환
if (type.startsWith("v2-")) return type;
@@ -845,7 +863,18 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// 컬럼 메타데이터 기반 componentConfig 병합 (DB 최신 설정 우선)
const isEntityJoinColumn = fieldName?.includes(".");
const baseColumnName = isEntityJoinColumn ? undefined : fieldName;
const rawMergedConfig = mergeColumnMeta(screenTableName, baseColumnName, component.component_config || {});
// ★ 2026-04-11 버그 픽스: V2PropertiesPanel.onUpdateProperty 는
// componentConfig (camelCase) 에 저장하는데 여기서는 component_config
// (snake_case) 만 읽고 있었음. 둘 다 fallback.
const savedConfig =
(component as any).componentConfig ||
component.component_config ||
{};
const rawMergedConfig = mergeColumnMeta(
screenTableName,
baseColumnName,
savedConfig,
);
// fieldType이 설정된 경우, source/inputType 보조 속성 자동 보완
const mergedComponentConfig = (() => {
@@ -0,0 +1,261 @@
"use client";
import React from "react";
import { ComponentRendererProps } from "@/types/component";
import { ButtonConfig, ButtonVariant } from "./types";
/**
* Button — 통합 단일 버튼 컴포넌트
*
* 흡수 대상:
* - v2-button-primary (base)
* - button-primary (legacy)
* - related-data-buttons (legacy, 버튼 그룹은 여러 button 배치로 대체)
*
* 변형:
* - variant: primary / secondary / default / destructive / outline / ghost
* - actionType: 12종 + custom
* - size: sm / md / lg
*/
export interface ButtonComponentProps extends ComponentRendererProps {
config?: ButtonConfig;
}
// variant 별 기본 스타일 (config override 가능)
const VARIANT_PRESETS: Record<
ButtonVariant,
{ background: string; color: string; border: string }
> = {
primary: {
background: "hsl(var(--primary))",
color: "hsl(var(--card))",
border: "1px solid hsl(var(--primary))",
},
secondary: {
background: "hsl(var(--muted-foreground))",
color: "hsl(var(--card))",
border: "1px solid hsl(var(--muted-foreground))",
},
default: {
background: "hsl(var(--border))",
color: "hsl(var(--foreground))",
border: "1px solid hsl(var(--border))",
},
destructive: {
background: "hsl(var(--destructive))",
color: "hsl(var(--card))",
border: "1px solid hsl(var(--destructive))",
},
outline: {
background: "transparent",
color: "hsl(var(--foreground))",
border: "1px solid hsl(var(--border))",
},
ghost: {
background: "transparent",
color: "hsl(var(--foreground))",
border: "1px solid transparent",
},
};
const SIZE_PRESETS: Record<
NonNullable<ButtonConfig["size"]>,
{ padding: string; fontSize: string; height: string }
> = {
sm: { padding: "4px 10px", fontSize: "11px", height: "26px" },
md: { padding: "6px 14px", fontSize: "13px", height: "32px" },
lg: { padding: "8px 18px", fontSize: "15px", height: "40px" },
};
export const ButtonComponent: React.FC<ButtonComponentProps> = ({
component,
isDesignMode = false,
isSelected = false,
onClick,
onDragStart,
onDragEnd,
config,
className,
style,
...props
}) => {
// ─── 4경로 머지 (Phase A-2 에서 확립된 표준 패턴) ────────────────────────
// ★ 주의: props.size 는 component 의 visual size {width, height} 객체이므로
// ButtonConfig.size (sm/md/lg 문자열) 와 충돌. fromProps 에 size 는 넣지
// 않고, 문자열 여부 체크 후 사용.
const fromProps: Partial<ButtonConfig> = {};
const p = props as any;
if (p.text !== undefined) fromProps.text = p.text;
if (typeof p.variant === "string") fromProps.variant = p.variant;
if (typeof p.actionType === "string") fromProps.actionType = p.actionType;
if (p.confirm !== undefined) fromProps.confirm = p.confirm;
if (typeof p.disabled === "boolean") fromProps.disabled = p.disabled;
if (typeof p.icon === "string") fromProps.icon = p.icon;
if (typeof p.iconPosition === "string") fromProps.iconPosition = p.iconPosition;
if (typeof p.backgroundColor === "string")
fromProps.backgroundColor = p.backgroundColor;
if (typeof p.textColor === "string") fromProps.textColor = p.textColor;
if (typeof p.borderRadius === "string") fromProps.borderRadius = p.borderRadius;
// 2) 4경로 머지 (마지막이 최우선)
const componentConfig = {
...config,
...((component as any).config ?? {}),
...((component as any).componentConfig ?? {}),
...fromProps,
} as ButtonConfig;
const text = componentConfig.text ?? "버튼";
const variant: ButtonVariant = componentConfig.variant ?? "primary";
// ★ componentConfig.size 는 가끔 {width, height} 객체가 섞여 올 수 있어 방어
const sizeKey: NonNullable<ButtonConfig["size"]> =
typeof componentConfig.size === "string" &&
["sm", "md", "lg"].includes(componentConfig.size)
? (componentConfig.size as NonNullable<ButtonConfig["size"]>)
: "md";
const disabled = componentConfig.disabled ?? false;
const icon = componentConfig.icon;
const iconPosition = componentConfig.iconPosition ?? "left";
const variantStyle = VARIANT_PRESETS[variant] ?? VARIANT_PRESETS.primary;
const sizeStyle = SIZE_PRESETS[sizeKey] ?? SIZE_PRESETS.md;
const buttonStyle: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
gap: "6px",
padding: sizeStyle.padding,
fontSize: sizeStyle.fontSize,
minHeight: sizeStyle.height,
fontWeight: 600,
border: variantStyle.border,
borderRadius: componentConfig.borderRadius ?? "6px",
background: componentConfig.backgroundColor ?? variantStyle.background,
color: componentConfig.textColor ?? variantStyle.color,
cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.5 : 1,
transition: "opacity 0.1s, transform 0.05s",
userSelect: "none",
whiteSpace: "nowrap",
width: "100%",
height: "100%",
...(component as any).style,
...style,
};
if (isDesignMode && isSelected) {
buttonStyle.outline = "2px solid hsl(var(--primary))";
buttonStyle.outlineOffset = "2px";
}
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (disabled) return;
if (isDesignMode) {
onClick?.();
return;
}
if (componentConfig.confirm && !window.confirm(componentConfig.confirm)) {
return;
}
onClick?.();
};
/* eslint-disable @typescript-eslint/no-unused-vars */
const {
selectedScreen: _1,
onZoneComponentDrop: _2,
onZoneClick: _3,
componentConfig: _4,
component: _5,
isSelected: _6,
onClick: _7,
onDragStart: _8,
onDragEnd: _9,
size: _10,
position: _11,
style: _12,
screenId: _13,
tableName: _14,
onRefresh: _15,
onClose: _16,
web_type: _17,
autoGeneration: _18,
isInteractive: _19,
formData: _20,
onFormDataChange: _21,
menuId: _22,
menuObjid: _23,
onSave: _24,
userId: _25,
userName: _26,
companyCode: _27,
isInModal: _28,
readonly: _29,
originalData: _30,
_originalData: _31,
_initialData: _32,
_groupedData: _33,
allComponents: _34,
onUpdateLayout: _35,
selectedRows: _36,
selectedRowsData: _37,
onSelectedRowsChange: _38,
sortBy: _39,
sortOrder: _40,
tableDisplayData: _41,
flowSelectedData: _42,
flowSelectedStepId: _43,
onFlowSelectedDataChange: _44,
onConfigChange: _45,
refreshKey: _46,
flowRefreshKey: _47,
onFlowRefresh: _48,
isPreview: _49,
groupedData: _50,
// ★ ButtonConfig 필드 — DOM 에 spread 되면 React warning. 제외.
text: _51,
variant: _52,
actionType: _53,
confirm: _54,
disabled: _55,
icon: _56,
iconPosition: _57,
backgroundColor: _58,
textColor: _59,
borderRadius: _60,
action: _61,
displayMode: _62,
iconTextPosition: _63,
iconGap: _64,
...domProps
} = props as any;
/* eslint-enable @typescript-eslint/no-unused-vars */
return (
<button
type="button"
style={buttonStyle}
className={className}
onClick={handleClick}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
disabled={disabled}
{...domProps}
>
{icon && iconPosition === "left" && (
<span aria-hidden>{/* TODO: lucide icon lookup (Phase F) */}{icon}</span>
)}
<span>{text}</span>
{icon && iconPosition === "right" && (
<span aria-hidden>{/* TODO: lucide icon lookup (Phase F) */}{icon}</span>
)}
</button>
);
};
export const ButtonWrapper: React.FC<ButtonComponentProps> = (props) => {
return <ButtonComponent {...props} />;
};
@@ -0,0 +1,175 @@
"use client";
import React from "react";
import type { ButtonConfig } from "./types";
/**
* Button ConfigPanel — V2PropertiesPanel 에서 호출되는 설정 패널.
*
* text / variant / size / actionType / confirm / 색상 / 아이콘 편집.
* Phase A-3 의 최소 구현; Phase F 에서 아이콘 선택기 / action flow 연결 등 확장.
*/
export interface ButtonConfigPanelProps {
config?: ButtonConfig;
onChange?: (config: ButtonConfig) => void;
onUpdateProperty?: (componentId: string, path: string, value: unknown) => void;
selectedComponent?: { id: string; config?: ButtonConfig; [k: string]: any };
}
export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
config,
onChange,
selectedComponent,
}) => {
const current: ButtonConfig =
(config as ButtonConfig) ||
(selectedComponent?.config as ButtonConfig) ||
{};
const patch = (p: Partial<ButtonConfig>) => {
onChange?.({ ...current, ...p });
};
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.text || ""}
onChange={(e) => patch({ text: e.target.value })}
placeholder="버튼"
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">
(variant)
</label>
<select
value={current.variant || "primary"}
onChange={(e) =>
patch({ variant: e.target.value as ButtonConfig["variant"] })
}
className="border-border bg-background w-full rounded border px-2 py-1 text-xs"
>
<option value="primary"> (Primary)</option>
<option value="secondary"> (Secondary)</option>
<option value="default"> (Default)</option>
<option value="destructive"> (Destructive)</option>
<option value="outline"> (Outline)</option>
<option value="ghost"> (Ghost)</option>
</select>
</div>
<div>
<label className="text-muted-foreground mb-1 block text-[0.62rem] font-semibold tracking-wider uppercase">
</label>
<select
value={current.size || "md"}
onChange={(e) =>
patch({ size: e.target.value as ButtonConfig["size"] })
}
className="border-border bg-background w-full rounded border px-2 py-1 text-xs"
>
<option value="sm"></option>
<option value="md"></option>
<option value="lg"></option>
</select>
</div>
<div>
<label className="text-muted-foreground mb-1 block text-[0.62rem] font-semibold tracking-wider uppercase">
</label>
<select
value={current.actionType || "save"}
onChange={(e) =>
patch({ actionType: e.target.value as ButtonConfig["actionType"] })
}
className="border-border bg-background w-full rounded border px-2 py-1 text-xs"
>
<option value="save"> (save)</option>
<option value="edit"> (edit)</option>
<option value="delete"> (delete)</option>
<option value="add"> (add)</option>
<option value="cancel"> (cancel)</option>
<option value="close"> (close)</option>
<option value="navigate"> (navigate)</option>
<option value="popup"> (popup)</option>
<option value="search"> (search)</option>
<option value="reset"> (reset)</option>
<option value="submit"> (submit)</option>
<option value="approval"> (approval)</option>
<option value="custom"> (custom)</option>
</select>
</div>
<div>
<label className="text-muted-foreground mb-1 block text-[0.62rem] font-semibold tracking-wider uppercase">
()
</label>
<input
type="text"
value={current.confirm || ""}
onChange={(e) => patch({ confirm: e.target.value || undefined })}
placeholder="비우면 즉시 실행"
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">
(override)
</label>
<input
type="color"
value={current.backgroundColor || "#3b82f6"}
onChange={(e) => patch({ backgroundColor: e.target.value })}
className="border-border bg-background h-7 w-full rounded border px-1"
/>
</div>
<div>
<label className="text-muted-foreground mb-1 block text-[0.62rem] font-semibold tracking-wider uppercase">
(override)
</label>
<input
type="color"
value={current.textColor || "#ffffff"}
onChange={(e) => patch({ textColor: e.target.value })}
className="border-border bg-background h-7 w-full rounded border px-1"
/>
</div>
<div>
<label className="text-muted-foreground mb-1 block text-[0.62rem] font-semibold tracking-wider uppercase">
</label>
<input
type="text"
value={current.borderRadius || "6px"}
onChange={(e) => patch({ borderRadius: e.target.value })}
placeholder="6px"
className="border-border bg-background w-full rounded border px-2 py-1 text-xs"
/>
</div>
<label className="flex items-center gap-2 text-xs">
<input
type="checkbox"
checked={!!current.disabled}
onChange={(e) => patch({ disabled: e.target.checked })}
/>
<span></span>
</label>
</div>
);
};
export default ButtonConfigPanel;
@@ -0,0 +1,29 @@
"use client";
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { ButtonDefinition } from "./index";
import { ButtonComponent } from "./ButtonComponent";
/**
* Button 렌더러
*
* AutoRegisteringComponentRenderer 를 상속하여 import 시점에 자동으로
* ComponentRegistry 에 등록된다. components/index.ts 에서 이 파일을 import
* 해야 등록이 실행됨.
*/
export class ButtonRenderer extends AutoRegisteringComponentRenderer {
static componentDefinition = ButtonDefinition;
render(): React.ReactElement {
return <ButtonComponent {...this.props} renderer={this} />;
}
}
// 자동 등록
ButtonRenderer.registerSelf();
// Hot reload (dev)
if (process.env.NODE_ENV === "development") {
ButtonRenderer.enableHotReload();
}
@@ -0,0 +1,64 @@
"use client";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import { ButtonWrapper } from "./ButtonComponent";
import { ButtonConfigPanel } from "./ButtonConfigPanel";
import type { ButtonConfig } from "./types";
/**
* Button — 통합 단일 버튼 컴포넌트 정의 (2026-04-11, Phase A-3)
*
* 흡수 대상:
* - v2-button-primary (base)
* - button-primary (legacy)
* - related-data-buttons (legacy; 버튼 그룹은 여러 `button` 배치로 대체)
*
* 변형:
* - variant: primary | secondary | default | destructive | outline | ghost
* - size: sm | md | lg
* - actionType: save/edit/delete/add/cancel/close/navigate/popup/search/reset/submit/approval/custom (13종)
* - confirm, icon, backgroundColor/textColor override
*
* 관련 문서:
* notes/gbpark/2026-04-11-component-unification-plan.md §3.5
*/
const DEFAULT_CONFIG: Partial<ButtonConfig> = {
text: "버튼",
variant: "primary",
size: "md",
actionType: "save",
borderRadius: "6px",
};
export const ButtonDefinition = createComponentDefinition({
id: "button",
name: "버튼",
name_eng: "Button",
description: "통합 단일 버튼. 6종 variant × 13종 actionType",
category: ComponentCategory.ACTION,
web_type: "button",
component: ButtonWrapper,
default_config: DEFAULT_CONFIG as Record<string, any>,
default_size: { width: 120, height: 36 },
config_panel: ButtonConfigPanel,
icon: "MousePointer",
tags: ["버튼", "button", "action", "click"],
version: "2.0.0",
author: "INVYONE",
documentation:
"notes/gbpark/2026-04-11-component-unification-plan.md#35-button",
// ─── INVYONE DataPort 선언 ───
dataPorts: {
inputs: [
{ name: "disabled", type: "value" },
{ name: "formData", type: "row" },
],
outputs: [{ name: "clicked", type: "value" }],
},
});
export type { ButtonConfig } from "./types";
export { ButtonComponent, ButtonWrapper } from "./ButtonComponent";
export { ButtonConfigPanel } from "./ButtonConfigPanel";
@@ -0,0 +1,60 @@
"use client";
import { ComponentConfig } from "@/types/component";
/**
* Button 컴포넌트 통합 설정 타입
*
* v2-button-primary + button-primary(legacy) + related-data-buttons(legacy) 흡수.
* 단일 버튼 + 12종 액션 타입 + 5종 스타일 variant 지원.
*/
export type ButtonVariant =
| "primary"
| "secondary"
| "default"
| "destructive"
| "outline"
| "ghost";
export type ButtonSize = "sm" | "md" | "lg";
export type ButtonActionType =
| "save"
| "edit"
| "delete"
| "add"
| "cancel"
| "close"
| "navigate"
| "popup"
| "search"
| "reset"
| "submit"
| "approval"
| "custom";
export interface ButtonConfig extends ComponentConfig {
/** 버튼 라벨 텍스트. 기본 '버튼'. */
text?: string;
/** 버튼 스타일 variant. 기본 'primary'. */
variant?: ButtonVariant;
/** 크기. 기본 'md'. */
size?: ButtonSize;
/** 액션 종류 (12종 + custom). 기본 'save'. */
actionType?: ButtonActionType;
/** 확인 메시지 (있으면 실행 전 confirm 팝업). */
confirm?: string;
/** 비활성 여부. 기본 false. */
disabled?: boolean;
/** 커스텀 아이콘 이름 (lucide). 선택. */
icon?: string;
/** 아이콘 위치. 기본 'left'. */
iconPosition?: "left" | "right";
/** 배경 색. variant 보다 우선. */
backgroundColor?: string;
/** 텍스트 색. variant 보다 우선. */
textColor?: string;
/** 모서리 반경. 기본 '6px'. */
borderRadius?: string;
}
@@ -0,0 +1,294 @@
"use client";
import React, { useState } from "react";
import { ComponentRendererProps } from "@/types/component";
import { ContainerConfig, ContainerType, ContainerTab } from "./types";
/**
* Container — 통합 레이아웃 컨테이너 컴포넌트
*
* containerType 으로 탭/섹션/아코디언/반복/조건부 분기. Phase C-2 최소 구현으로
* 각 모드는 **스켈레톤 렌더**. 내부 자식 배치는 Phase F 에서 정교화.
*/
const VALID_TYPES: ContainerType[] = [
"tabs",
"section",
"accordion",
"repeater",
"conditional",
];
export interface ContainerComponentProps extends ComponentRendererProps {
config?: ContainerConfig;
}
export const ContainerComponent: React.FC<ContainerComponentProps> = ({
component,
isDesignMode = false,
isSelected = false,
onClick,
onDragStart,
onDragEnd,
config,
className,
style,
...props
}) => {
const fromProps: Partial<ContainerConfig> = {};
const p = props as any;
if (typeof p.containerType === "string" && (VALID_TYPES as string[]).includes(p.containerType))
fromProps.containerType = p.containerType as ContainerType;
if (typeof p.title === "string") fromProps.title = p.title;
if (Array.isArray(p.tabs)) fromProps.tabs = p.tabs;
if (typeof p.defaultTab === "string") fromProps.defaultTab = p.defaultTab;
if (typeof p.sectionVariant === "string") fromProps.sectionVariant = p.sectionVariant;
if (typeof p.collapsible === "boolean") fromProps.collapsible = p.collapsible;
if (typeof p.defaultCollapsed === "boolean") fromProps.defaultCollapsed = p.defaultCollapsed;
if (typeof p.multiple === "boolean") fromProps.multiple = p.multiple;
if (typeof p.minRows === "number") fromProps.minRows = p.minRows;
if (typeof p.maxRows === "number") fromProps.maxRows = p.maxRows;
if (typeof p.addRowText === "string") fromProps.addRowText = p.addRowText;
if (typeof p.conditionField === "string") fromProps.conditionField = p.conditionField;
if (typeof p.conditionOperator === "string") fromProps.conditionOperator = p.conditionOperator;
if (typeof p.conditionValue === "string") fromProps.conditionValue = p.conditionValue;
if (typeof p.padding === "string") fromProps.padding = p.padding;
if (typeof p.transparent === "boolean") fromProps.transparent = p.transparent;
const componentConfig = {
...config,
...((component as any).config ?? {}),
...((component as any).componentConfig ?? {}),
...fromProps,
} as ContainerConfig;
const containerType: ContainerType = (VALID_TYPES as string[]).includes(
componentConfig.containerType as string,
)
? (componentConfig.containerType as ContainerType)
: "section";
const title = componentConfig.title;
const padding = componentConfig.padding ?? "12px";
const transparent = componentConfig.transparent ?? false;
const containerStyle: React.CSSProperties = {
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
background: transparent ? "transparent" : "hsl(var(--card))",
borderRadius: "8px",
border: transparent ? "1px dashed hsl(var(--border))" : "1px solid hsl(var(--border))",
overflow: "hidden",
...(component as any).style,
...style,
};
if (isDesignMode && isSelected) {
containerStyle.outline = "2px solid hsl(var(--primary))";
containerStyle.outlineOffset = "2px";
}
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
onClick?.();
};
/* eslint-disable @typescript-eslint/no-unused-vars */
const {
selectedScreen: _1, onZoneComponentDrop: _2, onZoneClick: _3,
componentConfig: _4, component: _5, isSelected: _6,
onClick: _7, onDragStart: _8, onDragEnd: _9,
size: _10, position: _11, style: _12,
screenId: _13, tableName: _14, onRefresh: _15, onClose: _16,
web_type: _17, autoGeneration: _18, isInteractive: _19,
formData: _20, onFormDataChange: _21,
menuId: _22, menuObjid: _23, onSave: _24,
userId: _25, userName: _26, companyCode: _27,
isInModal: _28, readonly: _29, originalData: _30,
_originalData: _31, _initialData: _32, _groupedData: _33,
allComponents: _34, onUpdateLayout: _35,
selectedRows: _36, selectedRowsData: _37, onSelectedRowsChange: _38,
sortBy: _39, sortOrder: _40, tableDisplayData: _41,
flowSelectedData: _42, flowSelectedStepId: _43, onFlowSelectedDataChange: _44,
onConfigChange: _45, refreshKey: _46, flowRefreshKey: _47, onFlowRefresh: _48,
isPreview: _49, groupedData: _50,
containerType: _51, title: _52, tabs: _53, defaultTab: _54,
sectionVariant: _55, collapsible: _56, defaultCollapsed: _57,
multiple: _58, minRows: _59, maxRows: _60, addRowText: _61,
conditionField: _62, conditionOperator: _63, conditionValue: _64,
padding: _65, transparent: _66, disabled: _67, required: _68,
...domProps
} = props as any;
/* eslint-enable @typescript-eslint/no-unused-vars */
// ─── tabs ──────────────────────────────────────────────────────────────
const [activeTab, setActiveTab] = useState(
componentConfig.defaultTab ?? componentConfig.tabs?.[0]?.id ?? "tab1",
);
const renderTabs = () => {
const tabs: ContainerTab[] = componentConfig.tabs ?? [
{ id: "tab1", label: "탭 1" },
{ id: "tab2", label: "탭 2" },
{ id: "tab3", label: "탭 3" },
];
return (
<>
<div style={{
display: "flex", gap: "0", borderBottom: "1px solid hsl(var(--border))",
background: "hsl(var(--muted))", flexShrink: 0,
}}>
{tabs.map((tab) => (
<button key={tab.id} type="button" onClick={() => !isDesignMode && setActiveTab(tab.id)}
style={{
padding: "8px 16px", fontSize: "12px", fontWeight: activeTab === tab.id ? 700 : 500,
color: activeTab === tab.id ? "hsl(var(--primary))" : "hsl(var(--muted-foreground))",
background: activeTab === tab.id ? "hsl(var(--card))" : "transparent",
border: "none", borderBottom: activeTab === tab.id ? "2px solid hsl(var(--primary))" : "2px solid transparent",
cursor: isDesignMode ? "default" : "pointer",
}}>
{tab.label}
</button>
))}
</div>
<div style={{ flex: 1, padding, minHeight: 0, overflow: "auto" }}>
<div style={{ color: "hsl(var(--muted-foreground))", fontSize: "11px", textAlign: "center", padding: "20px" }}>
[{activeTab}]
</div>
</div>
</>
);
};
// ─── section ───────────────────────────────────────────────────────────
const [collapsed, setCollapsed] = useState(componentConfig.defaultCollapsed ?? false);
const renderSection = () => {
const variant = componentConfig.sectionVariant ?? "card";
const collapsible = componentConfig.collapsible ?? false;
return (
<>
{title && (
<div style={{
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "8px 12px", borderBottom: "1px solid hsl(var(--border))",
background: variant === "paper" ? "hsl(var(--muted))" : "transparent",
}} onClick={collapsible ? () => setCollapsed(!collapsed) : undefined}>
<span style={{ fontSize: "13px", fontWeight: 700, color: "hsl(var(--foreground))" }}>
{collapsible && <span style={{ marginRight: "6px" }}>{collapsed ? "▶" : "▼"}</span>}
{title}
</span>
</div>
)}
{!collapsed && (
<div style={{ flex: 1, padding, minHeight: 0, overflow: "auto" }}>
<div style={{ color: "hsl(var(--muted-foreground))", fontSize: "11px", textAlign: "center", padding: "20px", border: "1px dashed hsl(var(--border))", borderRadius: "4px" }}>
</div>
</div>
)}
</>
);
};
// ─── accordion ─────────────────────────────────────────────────────────
const renderAccordion = () => (
<>
{["항목 1", "항목 2", "항목 3"].map((label, i) => (
<details key={i} open={i === 0} style={{ borderBottom: "1px solid hsl(var(--border))" }}>
<summary style={{
padding: "8px 12px", cursor: "pointer", fontSize: "12px", fontWeight: 600,
color: "hsl(var(--foreground))", background: "hsl(var(--muted))",
listStyle: "none", display: "flex", alignItems: "center", gap: "6px",
}}>
<span style={{ fontSize: "10px" }}></span> {label}
</summary>
<div style={{ padding, fontSize: "11px", color: "hsl(var(--muted-foreground))" }}>
{i + 1}
</div>
</details>
))}
</>
);
// ─── repeater ──────────────────────────────────────────────────────────
const renderRepeater = () => {
const addText = componentConfig.addRowText ?? "+ 행 추가";
return (
<>
{title && (
<div style={{ padding: "8px 12px", borderBottom: "1px solid hsl(var(--border))", fontSize: "13px", fontWeight: 700, color: "hsl(var(--foreground))" }}>
{title}
</div>
)}
<div style={{ flex: 1, padding, minHeight: 0, overflow: "auto" }}>
{[0, 1].map((i) => (
<div key={i} style={{
border: "1px dashed hsl(var(--border))", borderRadius: "4px",
padding: "10px", marginBottom: "8px", fontSize: "11px", color: "hsl(var(--muted-foreground))",
}}>
#{i + 1}
</div>
))}
<button type="button" disabled={isDesignMode} style={{
width: "100%", padding: "6px", border: "1px dashed hsl(var(--border))",
borderRadius: "4px", background: "transparent", color: "hsl(var(--muted-foreground))",
fontSize: "11px", cursor: isDesignMode ? "default" : "pointer",
}}>
{addText}
</button>
</div>
</>
);
};
// ─── conditional ───────────────────────────────────────────────────────
const renderConditional = () => (
<>
<div style={{
padding: "6px 12px", background: "hsl(var(--accent))",
borderBottom: "1px solid hsl(var(--border))", fontSize: "11px",
color: "hsl(var(--primary))", fontWeight: 600, display: "flex", alignItems: "center", gap: "6px",
}}>
{componentConfig.conditionField && (
<span style={{ fontWeight: 400, color: "hsl(var(--muted-foreground))" }}>
({componentConfig.conditionField} {componentConfig.conditionOperator ?? "="} {componentConfig.conditionValue ?? "?"})
</span>
)}
</div>
<div style={{ flex: 1, padding, minHeight: 0, overflow: "auto" }}>
<div style={{
color: "hsl(var(--muted-foreground))", fontSize: "11px", textAlign: "center",
padding: "20px", border: "1px dashed hsl(var(--border))", borderRadius: "4px",
}}>
</div>
</div>
</>
);
const renderBody = () => {
switch (containerType) {
case "tabs": return renderTabs();
case "accordion": return renderAccordion();
case "repeater": return renderRepeater();
case "conditional": return renderConditional();
case "section":
default: return renderSection();
}
};
return (
<div style={containerStyle} className={className}
onClick={handleClick} onDragStart={onDragStart} onDragEnd={onDragEnd} {...domProps}>
{renderBody()}
</div>
);
};
export const ContainerWrapper: React.FC<ContainerComponentProps> = (props) => {
return <ContainerComponent {...props} />;
};
@@ -0,0 +1,177 @@
"use client";
import React from "react";
import type { ContainerConfig, ContainerTab } from "./types";
export interface ContainerConfigPanelProps {
config?: ContainerConfig;
onChange?: (config: ContainerConfig) => void;
selectedComponent?: { id: string; config?: ContainerConfig; [k: string]: any };
}
export const ContainerConfigPanel: React.FC<ContainerConfigPanelProps> = ({
config, onChange, selectedComponent,
}) => {
const current: ContainerConfig =
(config as ContainerConfig) || (selectedComponent?.config as ContainerConfig) || {};
const patch = (p: Partial<ContainerConfig>) => onChange?.({ ...current, ...p });
const tabs: ContainerTab[] = current.tabs ?? [];
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>
<select value={current.containerType || "section"}
onChange={(e) => patch({ containerType: e.target.value as ContainerConfig["containerType"] })}
className="border-border bg-background w-full rounded border px-2 py-1 text-xs">
<option value="section"> (/)</option>
<option value="tabs"></option>
<option value="accordion"></option>
<option value="repeater"></option>
<option value="conditional"></option>
</select>
</div>
<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="컨테이너 제목"
className="border-border bg-background w-full rounded border px-2 py-1 text-xs" />
</div>
<label className="flex items-center gap-2 text-xs">
<input type="checkbox" checked={!!current.transparent}
onChange={(e) => patch({ transparent: e.target.checked })} />
<span> </span>
</label>
{current.containerType === "section" && (
<>
<div>
<label className="text-muted-foreground mb-1 block text-[0.62rem] font-semibold tracking-wider uppercase">
</label>
<select value={current.sectionVariant || "card"}
onChange={(e) => patch({ sectionVariant: e.target.value as ContainerConfig["sectionVariant"] })}
className="border-border bg-background w-full rounded border px-2 py-1 text-xs">
<option value="card"></option>
<option value="paper"></option>
<option value="plain"></option>
</select>
</div>
<label className="flex items-center gap-2 text-xs">
<input type="checkbox" checked={!!current.collapsible}
onChange={(e) => patch({ collapsible: e.target.checked })} />
<span> </span>
</label>
</>
)}
{current.containerType === "tabs" && (
<div className="border-border 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">
({tabs.length})
</span>
<button type="button"
onClick={() => patch({
tabs: [...tabs, { id: `tab${tabs.length + 1}`, label: `${tabs.length + 1}` }],
})}
className="border-border hover:bg-accent rounded border px-2 py-0.5 text-[0.65rem]">
+
</button>
</div>
{tabs.map((tab, idx) => (
<div key={idx} className="border-border mb-1 flex items-center gap-1.5 rounded border p-1.5">
<input type="text" value={tab.label}
onChange={(e) => {
const next = tabs.map((t, i) => i === idx ? { ...t, label: e.target.value } : t);
patch({ tabs: next });
}}
className="border-border bg-background flex-1 rounded border px-1.5 py-0.5 text-[0.65rem]" />
<button type="button"
onClick={() => patch({ tabs: tabs.filter((_, i) => i !== idx) })}
className="text-destructive text-[0.6rem]"></button>
</div>
))}
</div>
)}
{current.containerType === "repeater" && (
<>
<div>
<label className="text-muted-foreground mb-1 block text-[0.62rem] font-semibold tracking-wider uppercase">
</label>
<input type="text" value={current.addRowText || ""}
onChange={(e) => patch({ addRowText: e.target.value || undefined })}
placeholder="+ 행 추가"
className="border-border bg-background w-full rounded border px-2 py-1 text-xs" />
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-muted-foreground mb-1 block text-[0.55rem]"> </label>
<input type="number" value={current.minRows ?? ""}
onChange={(e) => patch({ minRows: e.target.value === "" ? undefined : Number(e.target.value) })}
className="border-border bg-background w-full rounded border px-1.5 py-0.5 text-[0.65rem]" />
</div>
<div>
<label className="text-muted-foreground mb-1 block text-[0.55rem]"> </label>
<input type="number" value={current.maxRows ?? ""}
onChange={(e) => patch({ maxRows: e.target.value === "" ? undefined : Number(e.target.value) })}
className="border-border bg-background w-full rounded border px-1.5 py-0.5 text-[0.65rem]" />
</div>
</div>
</>
)}
{current.containerType === "conditional" && (
<>
<div>
<label className="text-muted-foreground mb-1 block text-[0.62rem] font-semibold tracking-wider uppercase">
</label>
<input type="text" value={current.conditionField || ""}
onChange={(e) => patch({ conditionField: e.target.value || undefined })}
placeholder="예: status"
className="border-border bg-background w-full rounded border px-2 py-1 text-xs" />
</div>
<div className="grid grid-cols-2 gap-2">
<select value={current.conditionOperator || "="}
onChange={(e) => patch({ conditionOperator: e.target.value as ContainerConfig["conditionOperator"] })}
className="border-border bg-background rounded border px-1.5 py-0.5 text-[0.65rem]">
<option value="=">=</option>
<option value="!="></option>
<option value=">">{">"}</option>
<option value="<">{"<"}</option>
<option value="contains"></option>
</select>
<input type="text" value={current.conditionValue || ""}
onChange={(e) => patch({ conditionValue: e.target.value || undefined })}
placeholder="값"
className="border-border bg-background rounded border px-1.5 py-0.5 text-[0.65rem]" />
</div>
</>
)}
<div>
<label className="text-muted-foreground mb-1 block text-[0.62rem] font-semibold tracking-wider uppercase">
(CSS)
</label>
<input type="text" value={current.padding || ""}
onChange={(e) => patch({ padding: e.target.value || undefined })}
placeholder="12px"
className="border-border bg-background w-full rounded border px-2 py-1 text-xs" />
</div>
</div>
);
};
export default ContainerConfigPanel;
@@ -0,0 +1,20 @@
"use client";
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { ContainerDefinition } from "./index";
import { ContainerComponent } from "./ContainerComponent";
export class ContainerRenderer extends AutoRegisteringComponentRenderer {
static componentDefinition = ContainerDefinition;
render(): React.ReactElement {
return <ContainerComponent {...this.props} renderer={this} />;
}
}
ContainerRenderer.registerSelf();
if (process.env.NODE_ENV === "development") {
ContainerRenderer.enableHotReload();
}
@@ -0,0 +1,34 @@
"use client";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import { ContainerWrapper } from "./ContainerComponent";
import { ContainerConfigPanel } from "./ContainerConfigPanel";
const DEFAULT_CONFIG = {
containerType: "section",
sectionVariant: "card",
padding: "12px",
};
export const ContainerDefinition = createComponentDefinition({
id: "container",
name: "컨테이너",
name_eng: "Container",
description: "탭/섹션/아코디언/반복/조건부 통합 레이아웃 컨테이너",
category: ComponentCategory.LAYOUT,
web_type: "text",
component: ContainerWrapper,
default_config: DEFAULT_CONFIG as Record<string, any>,
default_size: { width: 600, height: 300 },
config_panel: ContainerConfigPanel,
icon: "LayoutGrid",
tags: ["컨테이너", "container", "탭", "섹션", "아코디언", "반복", "layout"],
version: "2.0.0",
author: "INVYONE",
documentation: "notes/gbpark/2026-04-11-component-unification-plan.md#39-container",
});
export type { ContainerConfig } from "./types";
export { ContainerComponent, ContainerWrapper } from "./ContainerComponent";
export { ContainerConfigPanel } from "./ContainerConfigPanel";
@@ -0,0 +1,82 @@
"use client";
import { ComponentConfig } from "@/types/component";
/**
* Container 컴포넌트 통합 설정 타입
*
* 11개의 레이아웃/컨테이너 계열 컴포넌트를 통합.
* containerType 으로 탭/섹션/아코디언/반복/조건부 분기.
*
* 흡수 대상 (11):
* - v2-tabs-widget (탭)
* - v2-section-card / v2-section-paper (섹션)
* - v2-repeat-container / v2-repeater (반복)
* - accordion-basic (아코디언)
* - section-card / section-paper (legacy)
* - tabs (legacy)
* - conditional-container (조건부)
* - repeat-container / repeat-screen-modal / repeater-field-group (legacy)
* - screen-split-panel (legacy)
*/
export type ContainerType =
| "tabs"
| "section"
| "accordion"
| "repeater"
| "conditional";
export type SectionVariant = "card" | "paper" | "plain";
export interface ContainerTab {
id: string;
label: string;
}
export interface ContainerConfig extends ComponentConfig {
/** 컨테이너 종류 */
containerType?: ContainerType;
/** 제목 */
title?: string;
// ─── tabs 전용 ───
/** 탭 목록 */
tabs?: ContainerTab[];
/** 기본 선택 탭 */
defaultTab?: string;
// ─── section 전용 ───
/** 섹션 스타일 */
sectionVariant?: SectionVariant;
/** 접기 가능 */
collapsible?: boolean;
/** 기본 접힌 상태 */
defaultCollapsed?: boolean;
// ─── accordion 전용 ───
/** 동시에 여러 항목 펼침 허용 */
multiple?: boolean;
// ─── repeater 전용 ───
/** 최소 행 수 */
minRows?: number;
/** 최대 행 수 */
maxRows?: number;
/** 행 추가 버튼 텍스트 */
addRowText?: string;
// ─── conditional 전용 ───
/** 표시 조건 필드 */
conditionField?: string;
/** 조건 연산자 */
conditionOperator?: "=" | "!=" | ">" | "<" | "contains";
/** 조건 값 */
conditionValue?: string;
// ─── 공통 ───
/** 패딩 (CSS) */
padding?: string;
/** 배경 투명 여부 */
transparent?: boolean;
}
@@ -0,0 +1,275 @@
"use client";
import React from "react";
import { ComponentRendererProps } from "@/types/component";
import { DividerConfig } from "./types";
import { getAdaptiveLabelColor } from "@/lib/utils/darkModeColor";
/**
* Divider — 통합 구분선 컴포넌트
*
* 기존 v2-divider-line, v2-split-line, divider-line(legacy) 을 흡수한
* 통합 컴포넌트. config.orientation 으로 가로/세로를 분기하고 config.text 가
* 있으면 가운데 텍스트 라벨을 표시한다.
*
* 통합 범위:
* - v2-divider-line: 가로 구분선 + 텍스트 옵션 → orientation='horizontal'
* - v2-split-line: 세로 분할선 (드래그는 다음 단계) → orientation='vertical'
* - divider-line: legacy v2-divider-line 과 동일 → 삭제 대상
*/
export interface DividerComponentProps extends ComponentRendererProps {
config?: DividerConfig;
}
export const DividerComponent: React.FC<DividerComponentProps> = ({
component,
isDesignMode = false,
isSelected = false,
onClick,
onDragStart,
onDragEnd,
config,
className,
style,
...props
}) => {
// DynamicComponentRenderer.tsx:882 가 mergedComponentConfig 를 top-level
// props 로 spread 해서 전달. 즉 text/color/thickness 등이 props 에 직접 있음.
// 4곳 전부 머지해서 어느 경로든 반영되게 한다.
const fromProps: Partial<DividerConfig> = {};
const p = props as any;
if (p.orientation !== undefined) fromProps.orientation = p.orientation;
if (p.text !== undefined) fromProps.text = p.text;
if (p.color !== undefined) fromProps.color = p.color;
if (p.textColor !== undefined) fromProps.textColor = p.textColor;
if (p.thickness !== undefined) fromProps.thickness = p.thickness;
if (p.lineStyle !== undefined) fromProps.lineStyle = p.lineStyle;
if (p.rounded !== undefined) fromProps.rounded = p.rounded;
const componentConfig = {
...config,
...((component as any).config ?? {}),
...((component as any).componentConfig ?? {}),
...fromProps, // top-level spread props 가 최종 우선
} as DividerConfig;
const orientation: "horizontal" | "vertical" =
componentConfig.orientation ?? "horizontal";
const color = componentConfig.color || "hsl(var(--border))";
const textColor = componentConfig.textColor || "hsl(var(--muted-foreground))";
const thickness = componentConfig.thickness || "1px";
const lineStyle = componentConfig.lineStyle || "solid";
const rounded = componentConfig.rounded ?? false;
const text = componentConfig.text;
const containerStyle: React.CSSProperties = {
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
position: "relative",
...(component as any).style,
...style,
};
if (isDesignMode) {
containerStyle.border = `1px dashed ${isSelected ? "hsl(var(--primary))" : "hsl(var(--border))"}`;
}
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
onClick?.();
};
// DOM 에 전달하면 안 되는 React-specific props 필터링
/* eslint-disable @typescript-eslint/no-unused-vars */
const {
selectedScreen: _1,
onZoneComponentDrop: _2,
onZoneClick: _3,
componentConfig: _4,
component: _5,
isSelected: _6,
onClick: _7,
onDragStart: _8,
onDragEnd: _9,
size: _10,
position: _11,
style: _12,
screenId: _13,
tableName: _14,
onRefresh: _15,
onClose: _16,
web_type: _17,
autoGeneration: _18,
isInteractive: _19,
formData: _20,
onFormDataChange: _21,
menuId: _22,
menuObjid: _23,
onSave: _24,
userId: _25,
userName: _26,
companyCode: _27,
isInModal: _28,
readonly: _29,
originalData: _30,
_originalData: _31,
_initialData: _32,
_groupedData: _33,
allComponents: _34,
onUpdateLayout: _35,
selectedRows: _36,
selectedRowsData: _37,
onSelectedRowsChange: _38,
sortBy: _39,
sortOrder: _40,
tableDisplayData: _41,
flowSelectedData: _42,
flowSelectedStepId: _43,
onFlowSelectedDataChange: _44,
onConfigChange: _45,
refreshKey: _46,
flowRefreshKey: _47,
onFlowRefresh: _48,
isPreview: _49,
groupedData: _50,
// ★ DividerConfig 필드 — DOM 에 spread 되면 React warning. 제외.
orientation: _51,
text: _52,
color: _53,
textColor: _54,
thickness: _55,
lineStyle: _56,
rounded: _57,
defaultValue: _58,
placeholder: _59,
// 기타 noise
value: _60,
valueDefault: _61,
maxLength: _62,
minLength: _63,
variant: _64,
disabled: _65,
required: _66,
helperText: _67,
readonly: _68,
...domProps
} = props as any;
/* eslint-enable @typescript-eslint/no-unused-vars */
// 라벨 (컴포넌트 상단)
const renderLabel = () =>
(component as any).label &&
(component as any).style?.labelDisplay !== false && (
<label
style={{
position: "absolute",
top: "-25px",
left: "0px",
fontSize: (component as any).style?.labelFontSize || "14px",
color: getAdaptiveLabelColor((component as any).style?.labelColor),
fontWeight: 500,
}}
>
{(component as any).label}
{(component as any).required && (
<span style={{ color: "hsl(var(--destructive))" }}>*</span>
)}
</label>
);
// Horizontal + text: "─── 텍스트 ───"
if (orientation === "horizontal" && text) {
return (
<div
style={containerStyle}
className={className}
onClick={handleClick}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
{...domProps}
>
{renderLabel()}
<div
style={{
flex: 1,
height: 0,
borderTop: `${thickness} ${lineStyle} ${color}`,
}}
/>
<span
style={{
padding: "0 12px",
color: textColor,
fontSize: "14px",
whiteSpace: "nowrap",
}}
>
{text}
</span>
<div
style={{
flex: 1,
height: 0,
borderTop: `${thickness} ${lineStyle} ${color}`,
}}
/>
</div>
);
}
// Horizontal line
if (orientation === "horizontal") {
return (
<div
style={containerStyle}
className={className}
onClick={handleClick}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
{...domProps}
>
{renderLabel()}
<div
style={{
width: "100%",
height: 0,
borderTop: `${thickness} ${lineStyle} ${color}`,
borderRadius: rounded ? "999px" : 0,
}}
/>
</div>
);
}
// Vertical line
return (
<div
style={containerStyle}
className={className}
onClick={handleClick}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
{...domProps}
>
{renderLabel()}
<div
style={{
width: 0,
height: "100%",
borderLeft: `${thickness} ${lineStyle} ${color}`,
}}
/>
</div>
);
};
/**
* 래퍼 — 외부에서 import 하기 편한 표준 이름
*/
export const DividerWrapper: React.FC<DividerComponentProps> = (props) => {
return <DividerComponent {...props} />;
};
@@ -0,0 +1,126 @@
"use client";
import React from "react";
import type { DividerConfig } from "./types";
/**
* Divider ConfigPanel — V2PropertiesPanel 에서 호출되는 설정 패널.
*
* 통합 divider 는 orientation / text / color / thickness / lineStyle / rounded
* 6가지 옵션만 노출. V2PropertiesPanel props 규약을 따른다.
*/
export interface DividerConfigPanelProps {
config?: DividerConfig;
onChange?: (config: DividerConfig) => void;
onUpdateProperty?: (componentId: string, path: string, value: unknown) => void;
selectedComponent?: { id: string; config?: DividerConfig; [k: string]: any };
}
export const DividerConfigPanel: React.FC<DividerConfigPanelProps> = ({
config,
onChange,
onUpdateProperty,
selectedComponent,
}) => {
const current: DividerConfig =
(config as DividerConfig) ||
(selectedComponent?.config as DividerConfig) ||
{};
const patch = (p: Partial<DividerConfig>) => {
const next = { ...current, ...p };
onChange?.(next);
if (selectedComponent?.id) {
Object.entries(p).forEach(([key, value]) => {
onUpdateProperty?.(selectedComponent.id, `config.${key}`, value);
});
}
};
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>
<select
value={current.orientation || "horizontal"}
onChange={(e) =>
patch({ orientation: e.target.value as DividerConfig["orientation"] })
}
className="border-border bg-background w-full rounded border px-2 py-1 text-xs"
>
<option value="horizontal"></option>
<option value="vertical"></option>
</select>
</div>
<div>
<label className="text-muted-foreground mb-1 block text-[0.62rem] font-semibold tracking-wider uppercase">
( )
</label>
<input
type="text"
value={current.text || ""}
onChange={(e) => patch({ text: e.target.value || undefined })}
placeholder="비우면 선만 표시"
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>
<input
type="color"
value={current.color || "#d1d5db"}
onChange={(e) => patch({ color: e.target.value })}
className="border-border bg-background h-7 w-full rounded border px-1"
/>
</div>
<div>
<label className="text-muted-foreground mb-1 block text-[0.62rem] font-semibold tracking-wider uppercase">
</label>
<input
type="text"
value={current.thickness || "1px"}
onChange={(e) => patch({ thickness: e.target.value })}
placeholder="1px"
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.lineStyle || "solid"}
onChange={(e) =>
patch({ lineStyle: e.target.value as DividerConfig["lineStyle"] })
}
className="border-border bg-background w-full rounded border px-2 py-1 text-xs"
>
<option value="solid"></option>
<option value="dashed"></option>
<option value="dotted"></option>
</select>
</div>
<label className="flex items-center gap-2 text-xs">
<input
type="checkbox"
checked={!!current.rounded}
onChange={(e) => patch({ rounded: e.target.checked })}
/>
<span> (horizontal )</span>
</label>
</div>
);
};
export default DividerConfigPanel;
@@ -0,0 +1,29 @@
"use client";
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { DividerDefinition } from "./index";
import { DividerComponent } from "./DividerComponent";
/**
* Divider 렌더러
*
* AutoRegisteringComponentRenderer 를 상속하여 import 시점에 자동으로
* ComponentRegistry 에 등록된다. components/index.ts 에서 이 파일을 import
* 해야 등록이 실행됨.
*/
export class DividerRenderer extends AutoRegisteringComponentRenderer {
static componentDefinition = DividerDefinition;
render(): React.ReactElement {
return <DividerComponent {...this.props} renderer={this} />;
}
}
// 자동 등록
DividerRenderer.registerSelf();
// Hot reload (dev)
if (process.env.NODE_ENV === "development") {
DividerRenderer.enableHotReload();
}
@@ -0,0 +1,57 @@
"use client";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import { DividerWrapper } from "./DividerComponent";
import { DividerConfigPanel } from "./DividerConfigPanel";
import type { DividerConfig } from "./types";
/**
* Divider — 통합 구분선 컴포넌트 정의 (2026-04-11, Phase A-1)
*
* 흡수 대상:
* - v2-divider-line (가로 구분선 + 텍스트)
* - v2-split-line (세로 분할선 + 드래그는 다음 단계)
* - divider-line (legacy, 삭제 대상)
*
* 변형:
* - orientation: 'horizontal' | 'vertical'
* - text: 가운데 라벨 (horizontal 전용)
* - lineStyle: solid | dashed | dotted
* - rounded: 둥근 끝
*
* 관련 문서:
* notes/gbpark/2026-04-11-component-unification-plan.md §3.8
*/
const DEFAULT_CONFIG: Partial<DividerConfig> = {
orientation: "horizontal",
color: "#d1d5db",
thickness: "1px",
lineStyle: "solid",
rounded: false,
};
export const DividerDefinition = createComponentDefinition({
id: "divider",
name: "구분선",
name_eng: "Divider",
description:
"영역 구분용 통합 구분선. 가로/세로 · 텍스트 · 스타일 옵션 지원",
category: ComponentCategory.LAYOUT,
web_type: "text",
component: DividerWrapper,
default_config: DEFAULT_CONFIG as Record<string, any>,
default_size: { width: 400, height: 2 },
config_panel: DividerConfigPanel,
icon: "SeparatorHorizontal",
tags: ["구분선", "divider", "line", "separator", "v2-divider-line"],
version: "2.0.0",
author: "INVYONE",
documentation:
"notes/gbpark/2026-04-11-component-unification-plan.md#38-divider",
});
export type { DividerConfig } from "./types";
export { DividerComponent, DividerWrapper } from "./DividerComponent";
export { DividerConfigPanel } from "./DividerConfigPanel";
@@ -0,0 +1,31 @@
"use client";
import { ComponentConfig } from "@/types/component";
/**
* Divider 컴포넌트 통합 설정 타입
*
* v2-divider-line, v2-split-line, divider-line(legacy) 세 컴포넌트를 흡수한
* 통합 구분선 규격. orientation 으로 가로/세로를 구분하고, text 옵션으로 가운데
* 텍스트 라벨을 선택적으로 표시한다.
*/
export type DividerOrientation = "horizontal" | "vertical";
export type DividerLineStyle = "solid" | "dashed" | "dotted";
export interface DividerConfig extends ComponentConfig {
/** 방향: horizontal (가로) 또는 vertical (세로). 기본값 'horizontal'. */
orientation?: DividerOrientation;
/** 가운데 표시 텍스트 (horizontal 에서만 표시됨). 비우면 선만. */
text?: string;
/** 선 색상. CSS color. 기본 '#d1d5db'. */
color?: string;
/** 텍스트 색상. 기본 '#6b7280'. */
textColor?: string;
/** 선 두께. '1px' / '2px' 등. 기본 '1px'. */
thickness?: string;
/** 선 스타일: solid / dashed / dotted. 기본 'solid'. */
lineStyle?: DividerLineStyle;
/** 둥근 끝 여부 (horizontal 전용). 기본 false. */
rounded?: boolean;
}
+18
View File
@@ -97,6 +97,24 @@ import "./v2-table-list/TableListRenderer";
import "./v2-text-display/TextDisplayRenderer";
import "./v2-pivot-grid/PivotGridRenderer";
import "./v2-divider-line/DividerLineRenderer";
// ============================================================
// ★ 통합 컴포넌트 (2026-04-11~, Phase A+)
// v2-* / legacy 폴더를 단일 컴포넌트로 흡수한 결과물.
// 기존 등록은 legacy alias 로 자동 리다이렉트됨.
// 관련 문서: notes/gbpark/2026-04-11-component-unification-plan.md
// ============================================================
import "./divider/DividerRenderer"; // v2-divider-line + v2-split-line + divider-line 흡수
import "./title/TitleRenderer"; // v2-text-display + text-display 흡수
import "./button/ButtonRenderer"; // v2-button-primary + button-primary + related-data-buttons 흡수
import "./search/SearchRenderer"; // v2-table-search-widget + table-search-widget + autocomplete-search-input 흡수
import "./input/InputRenderer"; // v2-input/select/date/... + 20+ 레거시 입력 컴포넌트 흡수
import "./stats/StatsRenderer"; // v2-aggregation-widget + v2-status-count + v2-card-display + legacy 흡수
// form 컴포넌트는 롤백됨 (2026-04-11): "폼" 은 별도 컴포넌트가 아닌
// 화면 디자이너의 3뷰 탭(목록/등록 팝업/수정 팝업) 구조로 처리할 예정.
// 관련: notes/gbpark/2026-04-11-component-unification-plan.md §3.2
import "./table/TableRenderer"; // v2-table-list + v2-table-grouped + v2-pivot-grid + v2-split-panel-layout + legacy 9종 흡수
import "./container/ContainerRenderer"; // v2-tabs-widget + v2-section-card/paper + v2-repeat-container + accordion + conditional + legacy 11종 흡수
import "./v2-repeat-container/RepeatContainerRenderer";
import "./v2-section-card/SectionCardRenderer";
import "./v2-section-paper/SectionPaperRenderer";
@@ -0,0 +1,431 @@
"use client";
import React, { useState, useEffect } from "react";
import { ComponentRendererProps } from "@/types/component";
import { InputConfig, InputFieldType } from "./types";
/**
* Input — 통합 필드 입력 컴포넌트
*
* FieldConfig.type (10종) 기반 내부 분기 렌더. 하나의 컴포넌트가 text/number/
* date/datetime/select/entity/checkbox/textarea/file/code 를 전부 처리한다.
*
* 관련: notes/gbpark/2026-04-11-component-unification-plan.md §3.4
*/
const VALID_TYPES: InputFieldType[] = [
"text",
"number",
"date",
"datetime",
"select",
"entity",
"checkbox",
"textarea",
"file",
"code",
];
function isValidType(v: unknown): v is InputFieldType {
return typeof v === "string" && (VALID_TYPES as string[]).includes(v);
}
export interface InputComponentProps extends ComponentRendererProps {
config?: InputConfig;
}
export const InputComponent: React.FC<InputComponentProps> = ({
component,
isDesignMode = false,
isSelected = false,
onClick,
onDragStart,
onDragEnd,
config,
className,
style,
...props
}) => {
// ─── 4경로 머지 (표준 패턴) ──────────────────────────────────────────
// ★ 주의: props.type 은 component.type ('widget'/'component') 일 수 있으므로
// InputFieldType 10종에 포함될 때만 사용.
const fromProps: Partial<InputConfig> = {};
const p = props as any;
if (isValidType(p.type)) fromProps.type = p.type;
if (typeof p.label === "string") fromProps.label = p.label;
if (typeof p.placeholder === "string") fromProps.placeholder = p.placeholder;
if (typeof p.helperText === "string") fromProps.helperText = p.helperText;
if (p.defaultValue !== undefined) fromProps.defaultValue = p.defaultValue;
if (typeof p.required === "boolean") fromProps.required = p.required;
if (typeof p.editable === "boolean") fromProps.editable = p.editable;
if (typeof p.readonly === "boolean") fromProps.readonly = p.readonly;
if (typeof p.disabled === "boolean") fromProps.disabled = p.disabled;
if (Array.isArray(p.options)) fromProps.options = p.options;
if (p.ref && typeof p.ref === "object" && !("current" in p.ref))
fromProps.ref = p.ref;
if (typeof p.format === "string") fromProps.format = p.format;
if (typeof p.computed === "string") fromProps.computed = p.computed;
if (typeof p.min === "number") fromProps.min = p.min;
if (typeof p.max === "number") fromProps.max = p.max;
if (typeof p.step === "number") fromProps.step = p.step;
if (typeof p.minLength === "number") fromProps.minLength = p.minLength;
if (typeof p.maxLength === "number") fromProps.maxLength = p.maxLength;
if (typeof p.rows === "number") fromProps.rows = p.rows;
if (typeof p.accept === "string") fromProps.accept = p.accept;
if (typeof p.multiple === "boolean") fromProps.multiple = p.multiple;
const componentConfig = {
...config,
...((component as any).config ?? {}),
...((component as any).componentConfig ?? {}),
...fromProps,
} as InputConfig;
const type: InputFieldType = isValidType(componentConfig.type)
? componentConfig.type
: "text";
const label = componentConfig.label;
const placeholder = componentConfig.placeholder ?? "";
const helperText = componentConfig.helperText;
const required = componentConfig.required ?? false;
const editable = componentConfig.editable !== false;
const readonly = componentConfig.readonly ?? !editable;
const disabled = componentConfig.disabled ?? false;
const rows = componentConfig.rows ?? 3;
const [value, setValue] = useState<unknown>(
componentConfig.defaultValue ?? "",
);
useEffect(() => {
setValue(componentConfig.defaultValue ?? "");
}, [componentConfig.defaultValue]);
// ─── DOM props filter (React warning 방지) ────────────────────────────
/* eslint-disable @typescript-eslint/no-unused-vars */
const {
selectedScreen: _1,
onZoneComponentDrop: _2,
onZoneClick: _3,
componentConfig: _4,
component: _5,
isSelected: _6,
onClick: _7,
onDragStart: _8,
onDragEnd: _9,
size: _10,
position: _11,
style: _12,
screenId: _13,
tableName: _14,
onRefresh: _15,
onClose: _16,
web_type: _17,
autoGeneration: _18,
isInteractive: _19,
formData: _20,
onFormDataChange: _21,
menuId: _22,
menuObjid: _23,
onSave: _24,
userId: _25,
userName: _26,
companyCode: _27,
isInModal: _28,
originalData: _30,
_originalData: _31,
_initialData: _32,
_groupedData: _33,
allComponents: _34,
onUpdateLayout: _35,
selectedRows: _36,
selectedRowsData: _37,
onSelectedRowsChange: _38,
sortBy: _39,
sortOrder: _40,
tableDisplayData: _41,
flowSelectedData: _42,
flowSelectedStepId: _43,
onFlowSelectedDataChange: _44,
onConfigChange: _45,
refreshKey: _46,
flowRefreshKey: _47,
onFlowRefresh: _48,
isPreview: _49,
groupedData: _50,
// ★ InputConfig 필드 — DOM 에 spread 되면 React warning. 제외.
type: _51,
label: _52,
placeholder: _53,
helperText: _54,
defaultValue: _55,
required: _56,
editable: _57,
readonly: _58,
disabled: _59,
options: _60,
ref: _61,
format: _62,
computed: _63,
min: _64,
max: _65,
step: _66,
minLength: _67,
maxLength: _68,
rows: _69,
accept: _70,
multiple: _71,
// 기타 noise
columnName: _72,
fieldKey: _73,
fieldType: _74,
inputType: _75,
value: _76,
...domProps
} = props as any;
/* eslint-enable @typescript-eslint/no-unused-vars */
const containerStyle: React.CSSProperties = {
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
gap: "2px",
padding: "4px 6px",
boxSizing: "border-box",
...(component as any).style,
...style,
};
if (isDesignMode && isSelected) {
containerStyle.outline = "2px solid hsl(var(--primary))";
containerStyle.outlineOffset = "2px";
}
const baseInputStyle: React.CSSProperties = {
width: "100%",
padding: "5px 8px",
fontSize: "13px",
border: "1px solid hsl(var(--border))",
borderRadius: "4px",
background: disabled ? "hsl(var(--muted))" : "hsl(var(--card))",
color: "hsl(var(--foreground))",
outline: "none",
boxSizing: "border-box",
};
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
onClick?.();
};
// ─── 타입별 입력 위젯 ─────────────────────────────────────────────────
const renderInput = () => {
const common = {
style: baseInputStyle,
disabled: disabled || isDesignMode,
readOnly: readonly,
placeholder,
};
switch (type) {
case "number":
return (
<input
type="number"
value={typeof value === "number" || typeof value === "string" ? (value as any) : ""}
onChange={(e) => setValue(e.target.valueAsNumber)}
min={componentConfig.min}
max={componentConfig.max}
step={componentConfig.step}
{...common}
/>
);
case "date":
return (
<input
type="date"
value={typeof value === "string" ? value : ""}
onChange={(e) => setValue(e.target.value)}
{...common}
/>
);
case "datetime":
return (
<input
type="datetime-local"
value={typeof value === "string" ? value : ""}
onChange={(e) => setValue(e.target.value)}
{...common}
/>
);
case "textarea":
return (
<textarea
value={typeof value === "string" ? value : ""}
onChange={(e) => setValue(e.target.value)}
rows={rows}
minLength={componentConfig.minLength}
maxLength={componentConfig.maxLength}
{...common}
style={{ ...baseInputStyle, resize: "vertical" }}
/>
);
case "select": {
const options = componentConfig.options ?? [];
return (
<select
value={typeof value === "string" ? value : ""}
onChange={(e) => setValue(e.target.value)}
{...common}
>
<option value="">{placeholder || "선택하세요"}</option>
{options.map((opt, i) => {
if (typeof opt === "string") {
return (
<option key={i} value={opt}>
{opt}
</option>
);
}
return (
<option key={i} value={opt.value}>
{opt.label}
</option>
);
})}
</select>
);
}
case "checkbox":
return (
<label
style={{
display: "inline-flex",
alignItems: "center",
gap: "6px",
fontSize: "13px",
cursor: disabled ? "not-allowed" : "pointer",
}}
>
<input
type="checkbox"
checked={!!value}
onChange={(e) => setValue(e.target.checked)}
disabled={disabled || isDesignMode}
readOnly={readonly}
/>
<span>{placeholder || label || "체크"}</span>
</label>
);
case "entity":
return (
<div style={{ display: "flex", gap: "4px" }}>
<input
type="text"
value={typeof value === "string" ? value : ""}
onChange={(e) => setValue(e.target.value)}
{...common}
readOnly
placeholder={placeholder || "검색 팝업에서 선택"}
/>
<button
type="button"
style={{
padding: "5px 10px",
fontSize: "12px",
border: "1px solid hsl(var(--border))",
background: "hsl(var(--muted))",
borderRadius: "4px",
cursor: disabled || isDesignMode ? "not-allowed" : "pointer",
}}
disabled={disabled || isDesignMode}
>
🔍
</button>
</div>
);
case "file":
return (
<input
type="file"
accept={componentConfig.accept}
multiple={componentConfig.multiple}
{...common}
style={{ ...baseInputStyle, padding: "3px 6px" }}
/>
);
case "code":
return (
<input
type="text"
value={typeof value === "string" ? value : ""}
{...common}
readOnly
style={{
...baseInputStyle,
background: "hsl(var(--muted))",
fontFamily: "monospace",
color: "hsl(var(--muted-foreground))",
}}
placeholder={placeholder || "자동채번"}
/>
);
case "text":
default:
return (
<input
type="text"
value={typeof value === "string" ? value : ""}
onChange={(e) => setValue(e.target.value)}
minLength={componentConfig.minLength}
maxLength={componentConfig.maxLength}
{...common}
/>
);
}
};
return (
<div
style={containerStyle}
className={className}
onClick={handleClick}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
{...domProps}
>
{label && (
<label
style={{
fontSize: "11px",
fontWeight: 600,
color: "hsl(var(--foreground))",
display: "flex",
alignItems: "center",
gap: "3px",
}}
>
{label}
{required && <span style={{ color: "hsl(var(--destructive))" }}>*</span>}
</label>
)}
{renderInput()}
{helperText && (
<span
style={{
fontSize: "10px",
color: "hsl(var(--muted-foreground))",
}}
>
{helperText}
</span>
)}
</div>
);
};
export const InputWrapper: React.FC<InputComponentProps> = (props) => {
return <InputComponent {...props} />;
};
@@ -0,0 +1,277 @@
"use client";
import React from "react";
import type { InputConfig, InputFieldType } from "./types";
/**
* Input ConfigPanel — 통합 입력 컴포넌트 설정 편집.
*
* 가장 중요한 건 `type` 필드. 선택한 type 에 따라 하위 옵션이 달라진다.
* Phase B-1 의 최소 구현; Phase F 에서 entity ref picker / options builder
* 등 고급 UI 추가 예정.
*/
export interface InputConfigPanelProps {
config?: InputConfig;
onChange?: (config: InputConfig) => void;
selectedComponent?: { id: string; config?: InputConfig; [k: string]: any };
}
export const InputConfigPanel: React.FC<InputConfigPanelProps> = ({
config,
onChange,
selectedComponent,
}) => {
const current: InputConfig =
(config as InputConfig) || (selectedComponent?.config as InputConfig) || {};
const patch = (p: Partial<InputConfig>) => {
onChange?.({ ...current, ...p });
};
const type: InputFieldType = (current.type as InputFieldType) || "text";
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>
<select
value={type}
onChange={(e) => patch({ type: e.target.value as InputFieldType })}
className="border-border bg-background w-full rounded border px-2 py-1 text-xs"
>
<option value="text">text </option>
<option value="number">number </option>
<option value="date">date </option>
<option value="datetime">datetime +</option>
<option value="select">select </option>
<option value="entity">entity FK ( )</option>
<option value="checkbox">checkbox </option>
<option value="textarea">textarea </option>
<option value="file">file </option>
<option value="code">code (readonly)</option>
</select>
</div>
{/* ─── 라벨 / placeholder ─── */}
<div>
<label className="text-muted-foreground mb-1 block text-[0.62rem] font-semibold tracking-wider uppercase">
</label>
<input
type="text"
value={current.label || ""}
onChange={(e) => patch({ label: e.target.value })}
placeholder="필드 라벨"
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">
placeholder
</label>
<input
type="text"
value={current.placeholder || ""}
onChange={(e) => patch({ placeholder: e.target.value })}
placeholder="입력 안내"
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>
<input
type="text"
value={current.helperText || ""}
onChange={(e) => patch({ helperText: e.target.value || undefined })}
placeholder="하단 도움말"
className="border-border bg-background w-full rounded border px-2 py-1 text-xs"
/>
</div>
{/* ─── 기본 토글 ─── */}
<label className="flex items-center gap-2 text-xs">
<input
type="checkbox"
checked={!!current.required}
onChange={(e) => patch({ required: e.target.checked })}
/>
<span> </span>
</label>
<label className="flex items-center gap-2 text-xs">
<input
type="checkbox"
checked={current.editable !== false}
onChange={(e) => patch({ editable: e.target.checked })}
/>
<span> </span>
</label>
<label className="flex items-center gap-2 text-xs">
<input
type="checkbox"
checked={!!current.disabled}
onChange={(e) => patch({ disabled: e.target.checked })}
/>
<span></span>
</label>
{/* ─── type 별 하위 옵션 ─── */}
{(type === "number") && (
<>
<div className="border-border mt-2 border-t pt-2">
<div className="text-muted-foreground mb-2 text-[0.6rem] font-semibold tracking-wider uppercase">
number
</div>
</div>
<div className="grid grid-cols-3 gap-2">
<div>
<label className="text-muted-foreground mb-1 block text-[0.55rem]">min</label>
<input
type="number"
value={current.min ?? ""}
onChange={(e) => patch({ min: e.target.value === "" ? undefined : Number(e.target.value) })}
className="border-border bg-background w-full rounded border px-1 py-0.5 text-[0.65rem]"
/>
</div>
<div>
<label className="text-muted-foreground mb-1 block text-[0.55rem]">max</label>
<input
type="number"
value={current.max ?? ""}
onChange={(e) => patch({ max: e.target.value === "" ? undefined : Number(e.target.value) })}
className="border-border bg-background w-full rounded border px-1 py-0.5 text-[0.65rem]"
/>
</div>
<div>
<label className="text-muted-foreground mb-1 block text-[0.55rem]">step</label>
<input
type="number"
value={current.step ?? ""}
onChange={(e) => patch({ step: e.target.value === "" ? undefined : Number(e.target.value) })}
className="border-border bg-background w-full rounded border px-1 py-0.5 text-[0.65rem]"
/>
</div>
</div>
</>
)}
{(type === "text" || type === "textarea") && (
<>
<div className="border-border mt-2 border-t pt-2">
<div className="text-muted-foreground mb-2 text-[0.6rem] font-semibold tracking-wider uppercase">
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-muted-foreground mb-1 block text-[0.55rem]">minLength</label>
<input
type="number"
value={current.minLength ?? ""}
onChange={(e) => patch({ minLength: e.target.value === "" ? undefined : Number(e.target.value) })}
className="border-border bg-background w-full rounded border px-1 py-0.5 text-[0.65rem]"
/>
</div>
<div>
<label className="text-muted-foreground mb-1 block text-[0.55rem]">maxLength</label>
<input
type="number"
value={current.maxLength ?? ""}
onChange={(e) => patch({ maxLength: e.target.value === "" ? undefined : Number(e.target.value) })}
className="border-border bg-background w-full rounded border px-1 py-0.5 text-[0.65rem]"
/>
</div>
</div>
</>
)}
{type === "textarea" && (
<div>
<label className="text-muted-foreground mb-1 block text-[0.62rem] font-semibold tracking-wider uppercase">
textarea
</label>
<input
type="number"
value={current.rows ?? 3}
onChange={(e) => patch({ rows: Number(e.target.value) })}
min={1}
max={20}
className="border-border bg-background w-full rounded border px-2 py-1 text-xs"
/>
</div>
)}
{type === "select" && (
<div>
<label className="text-muted-foreground mb-1 block text-[0.62rem] font-semibold tracking-wider uppercase">
select ( )
</label>
<input
type="text"
value={Array.isArray(current.options) ? current.options.map((o: any) => (typeof o === "string" ? o : o.label)).join(", ") : ""}
onChange={(e) => {
const options = e.target.value
.split(",")
.map((s) => s.trim())
.filter(Boolean);
patch({ options });
}}
placeholder="예: 확정, 취소, 대기"
className="border-border bg-background w-full rounded border px-2 py-1 text-xs"
/>
</div>
)}
{type === "file" && (
<>
<div>
<label className="text-muted-foreground mb-1 block text-[0.62rem] font-semibold tracking-wider uppercase">
</label>
<input
type="text"
value={current.accept || ""}
onChange={(e) => patch({ accept: e.target.value || undefined })}
placeholder="예: image/*, .pdf"
className="border-border bg-background w-full rounded border px-2 py-1 text-xs"
/>
</div>
<label className="flex items-center gap-2 text-xs">
<input
type="checkbox"
checked={!!current.multiple}
onChange={(e) => patch({ multiple: e.target.checked })}
/>
<span> </span>
</label>
</>
)}
{(type === "number" || type === "date" || type === "datetime") && (
<div>
<label className="text-muted-foreground mb-1 block text-[0.62rem] font-semibold tracking-wider uppercase">
(format)
</label>
<input
type="text"
value={current.format || ""}
onChange={(e) => patch({ format: e.target.value || undefined })}
placeholder={type === "number" ? "#,##0" : "YYYY-MM-DD"}
className="border-border bg-background w-full rounded border px-2 py-1 text-xs"
/>
</div>
)}
</div>
);
};
export default InputConfigPanel;
@@ -0,0 +1,29 @@
"use client";
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { InputDefinition } from "./index";
import { InputComponent } from "./InputComponent";
/**
* Input 렌더러
*
* AutoRegisteringComponentRenderer 를 상속하여 import 시점에 자동으로
* ComponentRegistry 에 등록된다. components/index.ts 에서 이 파일을 import
* 해야 등록이 실행됨.
*/
export class InputRenderer extends AutoRegisteringComponentRenderer {
static componentDefinition = InputDefinition;
render(): React.ReactElement {
return <InputComponent {...this.props} renderer={this} />;
}
}
// 자동 등록
InputRenderer.registerSelf();
// Hot reload (dev)
if (process.env.NODE_ENV === "development") {
InputRenderer.enableHotReload();
}
@@ -0,0 +1,64 @@
"use client";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import { InputWrapper } from "./InputComponent";
import { InputConfigPanel } from "./InputConfigPanel";
import type { InputConfig } from "./types";
/**
* Input — 범용 필드 입력 통합 컴포넌트 (2026-04-11, Phase B-1)
*
* 하나의 컴포넌트가 FieldConfig.type 10종을 전부 처리. 팔레트에는 1개만 나오고
* 사용자가 type 을 선택하면 해당 위젯으로 전환.
*
* 흡수 대상 (20+):
* v2-input, v2-select, v2-date, v2-category-manager, v2-file-upload,
* v2-media, v2-numbering-rule, v2-location-swap-selector,
* entity-search-input, autocomplete-search-input,
* text-input, number-input, date-input, select-basic, checkbox-basic,
* radio-basic, toggle-switch, slider-basic, textarea-basic,
* file-upload, image-display, image-widget,
* selected-items-detail-input, test-input
*
* 관련 문서:
* notes/gbpark/2026-04-11-component-unification-plan.md §3.4
*/
const DEFAULT_CONFIG: Partial<InputConfig> = {
type: "text",
placeholder: "입력하세요",
required: false,
editable: true,
};
export const InputDefinition = createComponentDefinition({
id: "input",
name: "입력",
name_eng: "Input",
description: "범용 필드 입력. FieldConfig.type 10종 지원",
category: ComponentCategory.INPUT,
web_type: "text",
component: InputWrapper,
default_config: DEFAULT_CONFIG as Record<string, any>,
default_size: { width: 240, height: 48 },
config_panel: InputConfigPanel,
icon: "Edit",
tags: ["입력", "input", "field", "text", "number", "date", "select"],
version: "2.0.0",
author: "INVYONE",
documentation:
"notes/gbpark/2026-04-11-component-unification-plan.md#34-input",
// ─── INVYONE DataPort 선언 ───
dataPorts: {
inputs: [{ name: "value", type: "value" }],
outputs: [
{ name: "value", type: "value" },
{ name: "changed", type: "value" },
],
},
});
export type { InputConfig } from "./types";
export { InputComponent, InputWrapper } from "./InputComponent";
export { InputConfigPanel } from "./InputConfigPanel";
@@ -0,0 +1,88 @@
"use client";
import { ComponentConfig } from "@/types/component";
import type { FieldRef, FieldOption } from "@/types/invyone-component";
/**
* Input 컴포넌트 통합 설정 타입
*
* 20+ 개의 기존 입력 계열 컴포넌트를 통합한 **범용 필드 입력**. FieldConfig.type
* 과 완전히 호환되어, 화면 수준 `fields: FieldConfig[]` 가 각 Input 컴포넌트에
* 그대로 흘러가도록 설계.
*
* 흡수 대상 (20+):
* v2-input / v2-select / v2-date / v2-category-manager / v2-file-upload /
* v2-media / v2-numbering-rule / entity-search-input / v2-location-swap-selector /
* text-input / number-input / date-input / select-basic / checkbox-basic /
* radio-basic / toggle-switch / slider-basic / textarea-basic / file-upload /
* image-display / image-widget / selected-items-detail-input / test-input
*/
export type InputFieldType =
| "text" // 일반 문자열
| "number" // 숫자
| "date" // 날짜
| "datetime" // 날짜+시간
| "select" // 드롭다운
| "entity" // FK 참조 (팝업 검색)
| "checkbox" // 체크박스
| "textarea" // 장문
| "file" // 파일 첨부
| "code"; // 자동채번 (readonly)
export interface InputConfig extends ComponentConfig {
/** 필드 타입 (렌더링 방식 결정) */
type?: InputFieldType;
/** 라벨 (상단 표시) */
label?: string;
/** placeholder / 기본 안내 텍스트 */
placeholder?: string;
/** 도움말 텍스트 (하단) */
helperText?: string;
/** 기본값 */
defaultValue?: unknown;
/** 필수 입력 여부 */
required?: boolean;
/** 편집 가능 여부 (false 면 readonly) */
editable?: boolean;
/** 읽기 전용 (editable 과 동의; editable 우선) */
readonly?: boolean;
/** 비활성화 */
disabled?: boolean;
// ─── 타입별 확장 ────────────────────────────────────────────────
/** select 타입: 선택지. FieldOption 과 호환. */
options?: FieldOption[];
/** entity 타입: FK 참조 정보 */
ref?: FieldRef;
/** 포맷 문자열 (number: '#,##0', date: 'YYYY-MM-DD') */
format?: string;
/** 자동 계산 수식 (computed) */
computed?: string;
// ─── number / slider ────────────────────────────────────────────
/** 최소값 */
min?: number;
/** 최대값 */
max?: number;
/** 증감 단위 */
step?: number;
// ─── text / textarea ────────────────────────────────────────────
/** 최소 길이 */
minLength?: number;
/** 최대 길이 */
maxLength?: number;
/** textarea 줄 수. 기본 3 */
rows?: number;
// ─── file ────────────────────────────────────────────────────────
/** 파일 허용 확장자 */
accept?: string;
/** 다중 선택 */
multiple?: boolean;
}
@@ -0,0 +1,252 @@
"use client";
import React, { useState } from "react";
import { ComponentRendererProps } from "@/types/component";
import { SearchConfig } from "./types";
/**
* Search — 통합 검색 필터 컴포넌트
*
* 흡수 대상:
* - v2-table-search-widget (base, 화면 테이블 자동 감지)
* - table-search-widget (legacy)
* - autocomplete-search-input (legacy, 자동완성은 input 그룹에서 처리)
*
* Phase A-4 최소 구현: 단일 input + 검색 버튼. 실제 검색 로직은 DataPort 로
* 다른 컴포넌트(table 등) 에 searchParams 전달. 복잡한 fields 설정 UI 는
* Phase F 에서 확장.
*/
export interface SearchComponentProps extends ComponentRendererProps {
config?: SearchConfig;
}
export const SearchComponent: React.FC<SearchComponentProps> = ({
component,
isDesignMode = false,
isSelected = false,
onClick,
onDragStart,
onDragEnd,
config,
className,
style,
...props
}) => {
// ─── 4경로 머지 ───────────────────────────────────────────────────────
const fromProps: Partial<SearchConfig> = {};
const p = props as any;
if (typeof p.placeholder === "string") fromProps.placeholder = p.placeholder;
if (typeof p.layout === "string") fromProps.layout = p.layout;
if (typeof p.dateRangeEnabled === "boolean")
fromProps.dateRangeEnabled = p.dateRangeEnabled;
if (typeof p.showResetButton === "boolean")
fromProps.showResetButton = p.showResetButton;
if (typeof p.autoSearch === "boolean") fromProps.autoSearch = p.autoSearch;
if (typeof p.searchButtonText === "string")
fromProps.searchButtonText = p.searchButtonText;
if (typeof p.helperText === "string") fromProps.helperText = p.helperText;
const componentConfig = {
...config,
...((component as any).config ?? {}),
...((component as any).componentConfig ?? {}),
...fromProps,
} as SearchConfig;
const placeholder = componentConfig.placeholder ?? "검색어를 입력하세요";
const layout = componentConfig.layout ?? "inline";
const showResetButton = componentConfig.showResetButton ?? true;
const searchButtonText = componentConfig.searchButtonText ?? "검색";
const helperText = componentConfig.helperText;
const [keyword, setKeyword] = useState("");
const handleSearch = () => {
if (isDesignMode) return;
// TODO: DataPort 연결 (Phase F)
// v2EventBus.emit('search:executed', { keyword })
};
const handleReset = () => {
setKeyword("");
};
const containerStyle: React.CSSProperties = {
width: "100%",
height: "100%",
display: "flex",
flexDirection: layout === "inline" ? "row" : "column",
alignItems: layout === "inline" ? "center" : "stretch",
gap: "6px",
padding: "6px 8px",
boxSizing: "border-box",
background: "transparent",
...(component as any).style,
...style,
};
if (isDesignMode && isSelected) {
containerStyle.outline = "2px solid hsl(var(--primary))";
containerStyle.outlineOffset = "2px";
}
const inputStyle: React.CSSProperties = {
flex: 1,
minWidth: 0,
padding: "6px 10px",
fontSize: "13px",
border: "1px solid hsl(var(--border))",
borderRadius: "6px",
background: "hsl(var(--card))",
color: "hsl(var(--foreground))",
outline: "none",
};
const btnStyle: React.CSSProperties = {
padding: "6px 14px",
fontSize: "13px",
fontWeight: 600,
border: "1px solid hsl(var(--primary))",
background: "hsl(var(--primary))",
color: "hsl(var(--card))",
borderRadius: "6px",
cursor: isDesignMode ? "default" : "pointer",
whiteSpace: "nowrap",
};
const resetBtnStyle: React.CSSProperties = {
...btnStyle,
background: "transparent",
color: "hsl(var(--muted-foreground))",
border: "1px solid hsl(var(--border))",
};
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
onClick?.();
};
/* eslint-disable @typescript-eslint/no-unused-vars */
const {
selectedScreen: _1,
onZoneComponentDrop: _2,
onZoneClick: _3,
componentConfig: _4,
component: _5,
isSelected: _6,
onClick: _7,
onDragStart: _8,
onDragEnd: _9,
size: _10,
position: _11,
style: _12,
screenId: _13,
tableName: _14,
onRefresh: _15,
onClose: _16,
web_type: _17,
autoGeneration: _18,
isInteractive: _19,
formData: _20,
onFormDataChange: _21,
menuId: _22,
menuObjid: _23,
onSave: _24,
userId: _25,
userName: _26,
companyCode: _27,
isInModal: _28,
readonly: _29,
originalData: _30,
_originalData: _31,
_initialData: _32,
_groupedData: _33,
allComponents: _34,
onUpdateLayout: _35,
selectedRows: _36,
selectedRowsData: _37,
onSelectedRowsChange: _38,
sortBy: _39,
sortOrder: _40,
tableDisplayData: _41,
flowSelectedData: _42,
flowSelectedStepId: _43,
onFlowSelectedDataChange: _44,
onConfigChange: _45,
refreshKey: _46,
flowRefreshKey: _47,
onFlowRefresh: _48,
isPreview: _49,
groupedData: _50,
// ★ SearchConfig 필드 — DOM 에 spread 되면 React warning. 제외.
placeholder: _51,
layout: _52,
dateRangeEnabled: _53,
showResetButton: _54,
autoSearch: _55,
searchButtonText: _56,
helperText: _57,
// 기타 noise
disabled: _58,
required: _59,
...domProps
} = props as any;
/* eslint-enable @typescript-eslint/no-unused-vars */
return (
<div
style={containerStyle}
className={className}
onClick={handleClick}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
{...domProps}
>
<input
type="text"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleSearch();
}}
placeholder={placeholder}
style={inputStyle}
disabled={isDesignMode}
/>
<button
type="button"
onClick={handleSearch}
style={btnStyle}
disabled={isDesignMode}
>
{searchButtonText}
</button>
{showResetButton && (
<button
type="button"
onClick={handleReset}
style={resetBtnStyle}
disabled={isDesignMode}
>
</button>
)}
{helperText && layout === "stacked" && (
<span
style={{
fontSize: "11px",
color: "hsl(var(--muted-foreground))",
marginTop: "2px",
}}
>
{helperText}
</span>
)}
</div>
);
};
export const SearchWrapper: React.FC<SearchComponentProps> = (props) => {
return <SearchComponent {...props} />;
};
@@ -0,0 +1,120 @@
"use client";
import React from "react";
import type { SearchConfig } from "./types";
/**
* Search ConfigPanel — V2PropertiesPanel 에서 호출되는 설정 패널.
*
* placeholder / layout / 검색 버튼 텍스트 / 초기화·자동검색·날짜범위 토글 편집.
* Phase A-4 의 최소 구현; Phase F 에서 fields (검색 대상 컬럼) 편집 UI 확장.
*/
export interface SearchConfigPanelProps {
config?: SearchConfig;
onChange?: (config: SearchConfig) => void;
selectedComponent?: { id: string; config?: SearchConfig; [k: string]: any };
}
export const SearchConfigPanel: React.FC<SearchConfigPanelProps> = ({
config,
onChange,
selectedComponent,
}) => {
const current: SearchConfig =
(config as SearchConfig) ||
(selectedComponent?.config as SearchConfig) ||
{};
const patch = (p: Partial<SearchConfig>) => {
onChange?.({ ...current, ...p });
};
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">
placeholder
</label>
<input
type="text"
value={current.placeholder || ""}
onChange={(e) => patch({ placeholder: e.target.value })}
placeholder="검색어를 입력하세요"
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.layout || "inline"}
onChange={(e) =>
patch({ layout: e.target.value as SearchConfig["layout"] })
}
className="border-border bg-background w-full rounded border px-2 py-1 text-xs"
>
<option value="inline"> ( )</option>
<option value="stacked"> ( )</option>
</select>
</div>
<div>
<label className="text-muted-foreground mb-1 block text-[0.62rem] font-semibold tracking-wider uppercase">
</label>
<input
type="text"
value={current.searchButtonText || ""}
onChange={(e) => patch({ searchButtonText: e.target.value })}
placeholder="검색"
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>
<input
type="text"
value={current.helperText || ""}
onChange={(e) => patch({ helperText: e.target.value || undefined })}
placeholder="예: 이름 · 사번 · 부서"
className="border-border bg-background w-full rounded border px-2 py-1 text-xs"
/>
</div>
<label className="flex items-center gap-2 text-xs">
<input
type="checkbox"
checked={current.showResetButton ?? true}
onChange={(e) => patch({ showResetButton: e.target.checked })}
/>
<span> </span>
</label>
<label className="flex items-center gap-2 text-xs">
<input
type="checkbox"
checked={!!current.autoSearch}
onChange={(e) => patch({ autoSearch: e.target.checked })}
/>
<span> (300ms )</span>
</label>
<label className="flex items-center gap-2 text-xs">
<input
type="checkbox"
checked={!!current.dateRangeEnabled}
onChange={(e) => patch({ dateRangeEnabled: e.target.checked })}
/>
<span> </span>
</label>
</div>
);
};
export default SearchConfigPanel;
@@ -0,0 +1,29 @@
"use client";
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { SearchDefinition } from "./index";
import { SearchComponent } from "./SearchComponent";
/**
* Search 렌더러
*
* AutoRegisteringComponentRenderer 를 상속하여 import 시점에 자동으로
* ComponentRegistry 에 등록된다. components/index.ts 에서 이 파일을 import
* 해야 등록이 실행됨.
*/
export class SearchRenderer extends AutoRegisteringComponentRenderer {
static componentDefinition = SearchDefinition;
render(): React.ReactElement {
return <SearchComponent {...this.props} renderer={this} />;
}
}
// 자동 등록
SearchRenderer.registerSelf();
// Hot reload (dev)
if (process.env.NODE_ENV === "development") {
SearchRenderer.enableHotReload();
}
@@ -0,0 +1,58 @@
"use client";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import { SearchWrapper } from "./SearchComponent";
import { SearchConfigPanel } from "./SearchConfigPanel";
import type { SearchConfig } from "./types";
/**
* Search — 통합 검색 필터 컴포넌트 정의 (2026-04-11, Phase A-4)
*
* 흡수 대상:
* - v2-table-search-widget (base, 기존 기능은 Phase F 에서 복구)
* - table-search-widget (legacy)
* - autocomplete-search-input (legacy; 자동완성은 input 그룹)
*
* 변형:
* - layout: inline | stacked
* - dateRangeEnabled, showResetButton, autoSearch, searchButtonText
*
* 관련 문서:
* notes/gbpark/2026-04-11-component-unification-plan.md §3.3
*/
const DEFAULT_CONFIG: Partial<SearchConfig> = {
placeholder: "검색어를 입력하세요",
layout: "inline",
showResetButton: true,
autoSearch: false,
searchButtonText: "검색",
};
export const SearchDefinition = createComponentDefinition({
id: "search",
name: "검색 필터",
name_eng: "Search",
description: "검색 입력 + 버튼. DataPort 로 table 등에 searchParams 전달",
category: ComponentCategory.UTILITY,
web_type: "text",
component: SearchWrapper,
default_config: DEFAULT_CONFIG as Record<string, any>,
default_size: { width: 480, height: 48 },
config_panel: SearchConfigPanel,
icon: "Search",
tags: ["검색", "search", "filter", "widget"],
version: "2.0.0",
author: "INVYONE",
documentation:
"notes/gbpark/2026-04-11-component-unification-plan.md#33-search",
// ─── INVYONE DataPort 선언 ───
dataPorts: {
outputs: [{ name: "searchParams", type: "params" }],
},
});
export type { SearchConfig } from "./types";
export { SearchComponent, SearchWrapper } from "./SearchComponent";
export { SearchConfigPanel } from "./SearchConfigPanel";
@@ -0,0 +1,29 @@
"use client";
import { ComponentConfig } from "@/types/component";
/**
* Search 컴포넌트 통합 설정 타입
*
* v2-table-search-widget + table-search-widget(legacy) + autocomplete-search-input(legacy) 흡수.
* 검색 필터 UI. 실제 검색 로직은 DataPort 로 타 컴포넌트(table 등) 에 전달.
*/
export type SearchLayout = "inline" | "stacked";
export interface SearchConfig extends ComponentConfig {
/** 검색 입력창 placeholder */
placeholder?: string;
/** 검색 필드 배치 방식 */
layout?: SearchLayout;
/** 날짜 범위 검색 활성화 */
dateRangeEnabled?: boolean;
/** 초기화 버튼 표시 */
showResetButton?: boolean;
/** 입력 시 자동 검색 (300ms 디바운스) */
autoSearch?: boolean;
/** 검색 버튼 텍스트. 기본 '검색'. */
searchButtonText?: string;
/** 검색 대상 안내 텍스트 */
helperText?: string;
}
@@ -0,0 +1,338 @@
"use client";
import React from "react";
import { ComponentRendererProps } from "@/types/component";
import { StatsConfig, StatsItem, StatsOrientation, StatsStyle } from "./types";
/**
* Stats — 통합 통계/KPI 카드 컴포넌트
*
* items 배열을 받아 각각을 카드/칩/빅넘버 형태로 렌더. 실제 데이터 집계는
* 상위 컴포넌트 또는 DataPort 로 받음. Phase B-2 는 **표시 로직만**, 연결은
* Phase F 에서.
*/
const VALID_ORIENTATIONS: StatsOrientation[] = ["horizontal", "vertical", "grid"];
const VALID_STYLES: StatsStyle[] = ["card", "chip", "bigNumber"];
export interface StatsComponentProps extends ComponentRendererProps {
config?: StatsConfig;
}
export const StatsComponent: React.FC<StatsComponentProps> = ({
component,
isDesignMode = false,
isSelected = false,
onClick,
onDragStart,
onDragEnd,
config,
className,
style,
...props
}) => {
// ─── 4경로 머지 ───────────────────────────────────────────────────────
const fromProps: Partial<StatsConfig> = {};
const p = props as any;
if (Array.isArray(p.items)) fromProps.items = p.items;
if (typeof p.orientation === "string" && (VALID_ORIENTATIONS as string[]).includes(p.orientation))
fromProps.orientation = p.orientation;
if (typeof p.style === "string" && (VALID_STYLES as string[]).includes(p.style))
fromProps.style = p.style;
if (typeof p.columns === "number") fromProps.columns = p.columns;
if (typeof p.sourceTable === "string") fromProps.sourceTable = p.sourceTable;
if (typeof p.title === "string") fromProps.title = p.title;
const componentConfig = {
...config,
...((component as any).config ?? {}),
...((component as any).componentConfig ?? {}),
...fromProps,
} as StatsConfig;
const items: StatsItem[] = componentConfig.items ?? [
{ label: "항목 1", value: 0 },
{ label: "항목 2", value: 0 },
{ label: "항목 3", value: 0 },
];
const orientation: StatsOrientation = componentConfig.orientation ?? "horizontal";
const statsStyle: StatsStyle = componentConfig.style ?? "card";
const columns = componentConfig.columns ?? Math.min(items.length || 1, 4);
const title = componentConfig.title;
const containerStyle: React.CSSProperties = {
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
gap: "8px",
padding: "8px",
boxSizing: "border-box",
...(component as any).style,
...style,
};
if (isDesignMode && isSelected) {
containerStyle.outline = "2px solid hsl(var(--primary))";
containerStyle.outlineOffset = "2px";
}
const itemsContainerStyle: React.CSSProperties = (() => {
if (orientation === "grid") {
return {
display: "grid",
gridTemplateColumns: `repeat(${columns}, 1fr)`,
gap: "8px",
flex: 1,
minHeight: 0,
};
}
return {
display: "flex",
flexDirection: orientation === "vertical" ? "column" : "row",
gap: "8px",
flex: 1,
minHeight: 0,
flexWrap: "wrap",
};
})();
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
onClick?.();
};
/* eslint-disable @typescript-eslint/no-unused-vars */
const {
selectedScreen: _1,
onZoneComponentDrop: _2,
onZoneClick: _3,
componentConfig: _4,
component: _5,
isSelected: _6,
onClick: _7,
onDragStart: _8,
onDragEnd: _9,
size: _10,
position: _11,
style: _12,
screenId: _13,
tableName: _14,
onRefresh: _15,
onClose: _16,
web_type: _17,
autoGeneration: _18,
isInteractive: _19,
formData: _20,
onFormDataChange: _21,
menuId: _22,
menuObjid: _23,
onSave: _24,
userId: _25,
userName: _26,
companyCode: _27,
isInModal: _28,
readonly: _29,
originalData: _30,
_originalData: _31,
_initialData: _32,
_groupedData: _33,
allComponents: _34,
onUpdateLayout: _35,
selectedRows: _36,
selectedRowsData: _37,
onSelectedRowsChange: _38,
sortBy: _39,
sortOrder: _40,
tableDisplayData: _41,
flowSelectedData: _42,
flowSelectedStepId: _43,
onFlowSelectedDataChange: _44,
onConfigChange: _45,
refreshKey: _46,
flowRefreshKey: _47,
onFlowRefresh: _48,
isPreview: _49,
groupedData: _50,
// ★ StatsConfig 필드 제외
items: _51,
orientation: _52,
style: _53_style,
columns: _54,
sourceTable: _55,
title: _56,
// 기타 noise
disabled: _57,
required: _58,
...domProps
} = props as any;
/* eslint-enable @typescript-eslint/no-unused-vars */
const renderItem = (item: StatsItem, idx: number) => {
const displayValue = item.value ?? 0;
const color = item.color ?? "hsl(var(--primary))";
if (statsStyle === "chip") {
return (
<div
key={idx}
style={{
display: "inline-flex",
alignItems: "center",
gap: "6px",
padding: "4px 10px",
borderRadius: "999px",
background: "hsl(var(--muted))",
fontSize: "12px",
}}
>
{item.icon && <span>{item.icon}</span>}
<span style={{ color: "hsl(var(--muted-foreground))" }}>{item.label}</span>
<strong style={{ color }}>{displayValue}</strong>
</div>
);
}
if (statsStyle === "bigNumber") {
return (
<div
key={idx}
style={{
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
gap: "2px",
padding: "8px 12px",
flex: 1,
minWidth: 0,
}}
>
<span
style={{
fontSize: "10px",
color: "hsl(var(--muted-foreground))",
textTransform: "uppercase",
letterSpacing: "0.05em",
}}
>
{item.icon && `${item.icon} `}
{item.label}
</span>
<span
style={{
fontSize: "28px",
fontWeight: 700,
color,
lineHeight: 1,
}}
>
{displayValue}
</span>
{item.delta && (
<span
style={{
fontSize: "11px",
color:
item.deltaDirection === "down"
? "hsl(var(--destructive))"
: item.deltaDirection === "up"
? "hsl(142 71% 45%)"
: "hsl(var(--muted-foreground))",
}}
>
{item.deltaDirection === "up" && "▲ "}
{item.deltaDirection === "down" && "▼ "}
{item.delta}
</span>
)}
</div>
);
}
// default: card
return (
<div
key={idx}
style={{
display: "flex",
flexDirection: "column",
gap: "4px",
padding: "10px 12px",
border: "1px solid hsl(var(--border))",
borderRadius: "8px",
background: "hsl(var(--card))",
flex: 1,
minWidth: 0,
}}
>
<span
style={{
fontSize: "11px",
color: "hsl(var(--muted-foreground))",
fontWeight: 600,
}}
>
{item.icon && `${item.icon} `}
{item.label}
</span>
<span
style={{
fontSize: "20px",
fontWeight: 700,
color,
lineHeight: 1.1,
}}
>
{displayValue}
</span>
{item.delta && (
<span
style={{
fontSize: "10px",
color:
item.deltaDirection === "down"
? "hsl(var(--destructive))"
: item.deltaDirection === "up"
? "hsl(142 71% 45%)"
: "hsl(var(--muted-foreground))",
}}
>
{item.deltaDirection === "up" && "▲ "}
{item.deltaDirection === "down" && "▼ "}
{item.delta}
</span>
)}
</div>
);
};
return (
<div
style={containerStyle}
className={className}
onClick={handleClick}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
{...domProps}
>
{title && (
<div
style={{
fontSize: "13px",
fontWeight: 700,
color: "hsl(var(--foreground))",
}}
>
{title}
</div>
)}
<div style={itemsContainerStyle}>
{items.map((item, idx) => renderItem(item, idx))}
</div>
</div>
);
};
export const StatsWrapper: React.FC<StatsComponentProps> = (props) => {
return <StatsComponent {...props} />;
};
@@ -0,0 +1,221 @@
"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]">
. &ldquo;+&rdquo; .
</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;
@@ -0,0 +1,27 @@
"use client";
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { StatsDefinition } from "./index";
import { StatsComponent } from "./StatsComponent";
/**
* Stats 렌더러
*
* AutoRegisteringComponentRenderer 상속으로 자동 등록.
*/
export class StatsRenderer extends AutoRegisteringComponentRenderer {
static componentDefinition = StatsDefinition;
render(): React.ReactElement {
return <StatsComponent {...this.props} renderer={this} />;
}
}
// 자동 등록
StatsRenderer.registerSelf();
// Hot reload (dev)
if (process.env.NODE_ENV === "development") {
StatsRenderer.enableHotReload();
}
@@ -0,0 +1,64 @@
"use client";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import { StatsWrapper } from "./StatsComponent";
import { StatsConfigPanel } from "./StatsConfigPanel";
import type { StatsConfig } from "./types";
/**
* Stats — 통합 통계/KPI 카드 컴포넌트 (2026-04-11, Phase B-2)
*
* 흡수 대상:
* - v2-aggregation-widget (base)
* - v2-status-count
* - v2-card-display
* - aggregation-widget (legacy)
* - card-display (legacy)
* - customer-item-mapping (legacy; 도메인 특화, Phase F 에서 별도 처리 고려)
*
* 변형:
* - style: card | chip | bigNumber
* - orientation: horizontal | vertical | grid
* - items: 각 KPI 항목 (라벨, 값, 아이콘, 색상, 변화량)
*
* 관련 문서:
* notes/gbpark/2026-04-11-component-unification-plan.md §3.6
*/
const DEFAULT_CONFIG: Partial<StatsConfig> = {
orientation: "horizontal",
style: "card",
items: [
{ label: "항목 1", value: 0, icon: "📊" },
{ label: "항목 2", value: 0, icon: "📈" },
{ label: "항목 3", value: 0, icon: "💰" },
],
};
export const StatsDefinition = createComponentDefinition({
id: "stats",
name: "통계/KPI",
name_eng: "Stats",
description: "통계/KPI 카드. items 기반 다중 지표 표시",
category: ComponentCategory.DISPLAY,
web_type: "text",
component: StatsWrapper,
default_config: DEFAULT_CONFIG as Record<string, any>,
default_size: { width: 480, height: 120 },
config_panel: StatsConfigPanel,
icon: "BarChart",
tags: ["통계", "kpi", "stats", "aggregation", "card"],
version: "2.0.0",
author: "INVYONE",
documentation:
"notes/gbpark/2026-04-11-component-unification-plan.md#36-stats",
// ─── INVYONE DataPort 선언 ───
dataPorts: {
inputs: [{ name: "data", type: "rows" }],
},
});
export type { StatsConfig } from "./types";
export { StatsComponent, StatsWrapper } from "./StatsComponent";
export { StatsConfigPanel } from "./StatsConfigPanel";
@@ -0,0 +1,51 @@
"use client";
import { ComponentConfig } from "@/types/component";
/**
* Stats 컴포넌트 통합 설정 타입
*
* 통계/KPI 카드 통합. v2-aggregation-widget + v2-status-count + v2-card-display
* + aggregation-widget(legacy) + card-display(legacy) + customer-item-mapping(legacy)
* 를 흡수.
*/
export type StatsAggregation = "count" | "sum" | "avg" | "min" | "max";
export type StatsOrientation = "horizontal" | "vertical" | "grid";
export type StatsStyle = "card" | "chip" | "bigNumber";
export interface StatsItem {
/** 라벨 */
label: string;
/** 집계 대상 컬럼 */
column?: string;
/** 집계 방식 */
aggregation?: StatsAggregation;
/** 고정 값 (column 없이 직접 지정) */
value?: string | number;
/** 라벨 앞 아이콘 (이모지 또는 lucide 이름) */
icon?: string;
/** 값 색상 */
color?: string;
/** 값 포맷 (#,##0 등) */
format?: string;
/** 변화량 (예: +12.4%) */
delta?: string;
/** 변화 방향: up / down / neutral */
deltaDirection?: "up" | "down" | "neutral";
}
export interface StatsConfig extends ComponentConfig {
/** 통계 항목 목록 */
items?: StatsItem[];
/** 배치 방향 */
orientation?: StatsOrientation;
/** 표시 스타일 */
style?: StatsStyle;
/** 그리드 열 수 (orientation='grid' 일 때) */
columns?: number;
/** 데이터 소스 테이블 (선택) */
sourceTable?: string;
/** 제목 */
title?: string;
}
@@ -0,0 +1,562 @@
"use client";
import React from "react";
import { ComponentRendererProps } from "@/types/component";
import {
TableConfig,
TableDisplayMode,
TableColumn,
TableRowHeight,
} from "./types";
/**
* Table — 통합 데이터 테이블 컴포넌트
*
* displayMode 로 기본/분할/그룹/피벗/카드 모드 분기. Phase C-1 최소 구현으로
* 각 모드는 **스켈레톤 렌더** 만. 실제 데이터 조회/정렬/페이지네이션 로직은
* Phase F 에서 DB 연결 + FieldConfig 기반 자동화로 확장.
*
* 관련: notes/gbpark/2026-04-11-component-unification-plan.md §3.1
*/
const VALID_MODES: TableDisplayMode[] = [
"table",
"split",
"grouped",
"pivot",
"card",
];
const ROW_HEIGHT_PRESETS: Record<TableRowHeight, string> = {
compact: "28px",
normal: "36px",
relaxed: "44px",
};
export interface TableComponentProps extends ComponentRendererProps {
config?: TableConfig;
}
export const TableComponent: React.FC<TableComponentProps> = ({
component,
isDesignMode = false,
isSelected = false,
onClick,
onDragStart,
onDragEnd,
config,
className,
style,
...props
}) => {
// ─── 4경로 머지 ───────────────────────────────────────────────────────
const fromProps: Partial<TableConfig> = {};
const p = props as any;
if (typeof p.displayMode === "string" && (VALID_MODES as string[]).includes(p.displayMode))
fromProps.displayMode = p.displayMode as TableDisplayMode;
if (Array.isArray(p.columns)) fromProps.columns = p.columns;
if (Array.isArray(p.fields)) fromProps.fields = p.fields;
if (typeof p.selectionMode === "string") fromProps.selectionMode = p.selectionMode;
if (typeof p.showCheckbox === "boolean") fromProps.showCheckbox = p.showCheckbox;
if (typeof p.showHeader === "boolean") fromProps.showHeader = p.showHeader;
if (typeof p.showFooter === "boolean") fromProps.showFooter = p.showFooter;
if (p.pagination && typeof p.pagination === "object")
fromProps.pagination = p.pagination;
if (typeof p.rowHeight === "string") fromProps.rowHeight = p.rowHeight;
if (typeof p.striped === "boolean") fromProps.striped = p.striped;
if (typeof p.hoverable === "boolean") fromProps.hoverable = p.hoverable;
if (typeof p.bordered === "boolean") fromProps.bordered = p.bordered;
if (typeof p.splitRatio === "number") fromProps.splitRatio = p.splitRatio;
if (typeof p.groupBy === "string") fromProps.groupBy = p.groupBy;
if (typeof p.emptyMessage === "string") fromProps.emptyMessage = p.emptyMessage;
if (typeof p.showToolbar === "boolean") fromProps.showToolbar = p.showToolbar;
if (typeof p.showExcel === "boolean") fromProps.showExcel = p.showExcel;
if (typeof p.showRefresh === "boolean") fromProps.showRefresh = p.showRefresh;
const componentConfig = {
...config,
...((component as any).config ?? {}),
...((component as any).componentConfig ?? {}),
...fromProps,
} as TableConfig;
const displayMode: TableDisplayMode = (VALID_MODES as string[]).includes(
componentConfig.displayMode as string,
)
? (componentConfig.displayMode as TableDisplayMode)
: "table";
// ─── columns 결정 (fields 우선, 없으면 columns, 없으면 placeholder) ───
const columns: TableColumn[] = (() => {
if (Array.isArray(componentConfig.fields) && componentConfig.fields.length > 0) {
return componentConfig.fields
.filter((f) => f.visible !== false && !f.system)
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
.map<TableColumn>((f) => ({
key: f.column,
label: f.label,
width: f.width,
align: f.align ?? (f.type === "number" ? "right" : "left"),
sortable: f.sortable ?? true,
visible: f.visible !== false,
format: f.format,
}));
}
if (Array.isArray(componentConfig.columns) && componentConfig.columns.length > 0) {
return componentConfig.columns.filter((c) => c.visible !== false);
}
return [
{ key: "col1", label: "컬럼 1" },
{ key: "col2", label: "컬럼 2" },
{ key: "col3", label: "컬럼 3" },
];
})();
const showHeader = componentConfig.showHeader !== false;
const showFooter = componentConfig.showFooter !== false;
const showCheckbox = componentConfig.showCheckbox ?? false;
const striped = componentConfig.striped ?? true;
const hoverable = componentConfig.hoverable ?? true;
const rowHeight = ROW_HEIGHT_PRESETS[componentConfig.rowHeight ?? "normal"];
const showToolbar = componentConfig.showToolbar ?? true;
const emptyMessage = componentConfig.emptyMessage ?? "데이터가 없습니다";
const containerStyle: React.CSSProperties = {
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
background: "hsl(var(--card))",
borderRadius: "6px",
border: "1px solid hsl(var(--border))",
overflow: "hidden",
...(component as any).style,
...style,
};
if (isDesignMode && isSelected) {
containerStyle.outline = "2px solid hsl(var(--primary))";
containerStyle.outlineOffset = "2px";
}
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
onClick?.();
};
/* eslint-disable @typescript-eslint/no-unused-vars */
const {
selectedScreen: _1,
onZoneComponentDrop: _2,
onZoneClick: _3,
componentConfig: _4,
component: _5,
isSelected: _6,
onClick: _7,
onDragStart: _8,
onDragEnd: _9,
size: _10,
position: _11,
style: _12,
screenId: _13,
tableName: _14,
onRefresh: _15,
onClose: _16,
web_type: _17,
autoGeneration: _18,
isInteractive: _19,
formData: _20,
onFormDataChange: _21,
menuId: _22,
menuObjid: _23,
onSave: _24,
userId: _25,
userName: _26,
companyCode: _27,
isInModal: _28,
readonly: _29,
originalData: _30,
_originalData: _31,
_initialData: _32,
_groupedData: _33,
allComponents: _34,
onUpdateLayout: _35,
selectedRows: _36,
selectedRowsData: _37,
onSelectedRowsChange: _38,
sortBy: _39,
sortOrder: _40,
tableDisplayData: _41,
flowSelectedData: _42,
flowSelectedStepId: _43,
onFlowSelectedDataChange: _44,
onConfigChange: _45,
refreshKey: _46,
flowRefreshKey: _47,
onFlowRefresh: _48,
isPreview: _49,
groupedData: _50,
// ★ TableConfig 필드 제외
displayMode: _51,
columns: _52,
fields: _53,
selectionMode: _54,
showCheckbox: _55,
showHeader: _56,
showFooter: _57,
pagination: _58,
rowHeight: _59,
striped: _60,
hoverable: _61,
bordered: _62,
splitRatio: _63,
groupBy: _64,
pivotRows: _65,
pivotColumns: _66,
pivotValues: _67,
emptyMessage: _68,
showToolbar: _69,
showExcel: _70,
showRefresh: _71,
disabled: _72,
required: _73,
...domProps
} = props as any;
/* eslint-enable @typescript-eslint/no-unused-vars */
const renderToolbar = () =>
showToolbar && (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "6px 10px",
borderBottom: "1px solid hsl(var(--border))",
background: "hsl(var(--muted))",
flexShrink: 0,
}}
>
<span style={{ fontSize: "11px", color: "hsl(var(--muted-foreground))", fontWeight: 600 }}>
{displayMode.toUpperCase()} · {columns.length}
</span>
<div style={{ display: "flex", gap: "4px" }}>
{componentConfig.showRefresh && (
<button type="button" style={toolbarBtnStyle} disabled={isDesignMode}>
</button>
)}
{componentConfig.showExcel && (
<button type="button" style={toolbarBtnStyle} disabled={isDesignMode}>
📊
</button>
)}
</div>
</div>
);
const renderBasicTable = () => (
<div style={{ flex: 1, minHeight: 0, overflow: "auto" }}>
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: "12px" }}>
{showHeader && (
<thead
style={{
background: "hsl(var(--muted))",
position: "sticky",
top: 0,
zIndex: 1,
}}
>
<tr>
{showCheckbox && (
<th style={{ ...thStyle, width: "32px", textAlign: "center" }}>
<input type="checkbox" disabled={isDesignMode} />
</th>
)}
{columns.map((col) => (
<th
key={col.key}
style={{
...thStyle,
width: col.width ? `${col.width}px` : undefined,
textAlign: col.align ?? "left",
}}
>
{col.label}
{col.sortable && (
<span style={{ marginLeft: "4px", color: "hsl(var(--muted-foreground))", fontSize: "10px" }}>
</span>
)}
</th>
))}
</tr>
</thead>
)}
<tbody>
{[0, 1, 2].map((rowIdx) => (
<tr
key={rowIdx}
style={{
height: rowHeight,
background: striped && rowIdx % 2 === 1 ? "hsl(var(--muted))" : "transparent",
borderBottom: "1px solid hsl(var(--muted))",
cursor: hoverable ? "pointer" : "default",
}}
>
{showCheckbox && (
<td style={{ ...tdStyle, textAlign: "center" }}>
<input type="checkbox" disabled={isDesignMode} />
</td>
)}
{columns.map((col) => (
<td
key={col.key}
style={{ ...tdStyle, textAlign: col.align ?? "left" }}
>
<span style={{ color: "hsl(var(--muted-foreground))" }}>...</span>
</td>
))}
</tr>
))}
{columns.length === 0 && (
<tr>
<td
colSpan={1}
style={{
padding: "24px",
textAlign: "center",
color: "hsl(var(--muted-foreground))",
fontSize: "11px",
}}
>
{emptyMessage}
</td>
</tr>
)}
</tbody>
</table>
</div>
);
const renderSplitMode = () => {
const ratio = componentConfig.splitRatio ?? 0.5;
return (
<div style={{ flex: 1, display: "flex", minHeight: 0 }}>
<div
style={{
flex: ratio,
borderRight: "1px solid hsl(var(--border))",
minWidth: 0,
overflow: "auto",
}}
>
{renderBasicTable()}
</div>
<div
style={{
flex: 1 - ratio,
padding: "12px",
background: "hsl(var(--muted))",
minWidth: 0,
}}
>
<div
style={{
fontSize: "11px",
color: "hsl(var(--muted-foreground))",
fontWeight: 600,
marginBottom: "8px",
}}
>
</div>
<div
style={{
border: "1px dashed hsl(var(--border))",
borderRadius: "4px",
padding: "20px",
textAlign: "center",
color: "hsl(var(--muted-foreground))",
fontSize: "11px",
}}
>
<div style={{ fontSize: "10px", marginTop: "4px" }}>
( )
</div>
</div>
</div>
</div>
);
};
const renderGroupedMode = () => {
const groupBy = componentConfig.groupBy;
return (
<div style={{ flex: 1, minHeight: 0, overflow: "auto" }}>
{["그룹 A", "그룹 B"].map((groupLabel) => (
<div key={groupLabel}>
<div
style={{
padding: "6px 10px",
background: "hsl(var(--accent))",
borderBottom: "1px solid hsl(var(--accent))",
fontSize: "11px",
fontWeight: 700,
color: "hsl(var(--primary))",
}}
>
{groupBy ? `${groupBy} = ${groupLabel}` : groupLabel}
</div>
<div style={{ padding: "0 16px" }}>{renderBasicTable()}</div>
</div>
))}
</div>
);
};
const renderPivotMode = () => (
<div
style={{
flex: 1,
minHeight: 0,
padding: "16px",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "hsl(var(--muted-foreground))",
fontSize: "12px",
background: "hsl(var(--muted))",
textAlign: "center",
}}
>
<div>
<div style={{ fontSize: "24px", marginBottom: "8px" }}>📊</div>
<div style={{ fontSize: "10px", marginTop: "4px" }}>
(Phase F placeholder)
</div>
</div>
</div>
);
const renderCardMode = () => (
<div
style={{
flex: 1,
minHeight: 0,
overflow: "auto",
padding: "8px",
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))",
gap: "8px",
}}
>
{[0, 1, 2].map((i) => (
<div
key={i}
style={{
border: "1px solid hsl(var(--border))",
borderRadius: "6px",
padding: "10px",
background: "hsl(var(--card))",
}}
>
{columns.map((col) => (
<div key={col.key} style={{ fontSize: "11px", marginBottom: "4px" }}>
<span style={{ color: "hsl(var(--muted-foreground))", marginRight: "4px" }}>{col.label}:</span>
<span style={{ color: "hsl(var(--muted-foreground))" }}>...</span>
</div>
))}
</div>
))}
</div>
);
const renderBody = () => {
switch (displayMode) {
case "split":
return renderSplitMode();
case "grouped":
return renderGroupedMode();
case "pivot":
return renderPivotMode();
case "card":
return renderCardMode();
case "table":
default:
return renderBasicTable();
}
};
return (
<div
style={containerStyle}
className={className}
onClick={handleClick}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
{...domProps}
>
{renderToolbar()}
{renderBody()}
{showFooter && componentConfig.pagination?.enabled !== false && (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "6px 10px",
borderTop: "1px solid hsl(var(--border))",
background: "hsl(var(--muted))",
fontSize: "11px",
color: "hsl(var(--muted-foreground))",
flexShrink: 0,
}}
>
<span> 0</span>
<div style={{ display: "flex", gap: "4px" }}>
<button type="button" style={toolbarBtnStyle} disabled>
</button>
<span style={{ padding: "2px 8px" }}>1 / 1</span>
<button type="button" style={toolbarBtnStyle} disabled>
</button>
</div>
</div>
)}
</div>
);
};
const thStyle: React.CSSProperties = {
padding: "8px 10px",
fontSize: "11px",
fontWeight: 700,
color: "hsl(var(--foreground))",
textTransform: "uppercase",
letterSpacing: "0.03em",
borderBottom: "1px solid hsl(var(--border))",
whiteSpace: "nowrap",
};
const tdStyle: React.CSSProperties = {
padding: "6px 10px",
fontSize: "12px",
color: "hsl(var(--foreground))",
};
const toolbarBtnStyle: React.CSSProperties = {
padding: "2px 8px",
fontSize: "11px",
border: "1px solid hsl(var(--border))",
background: "hsl(var(--card))",
borderRadius: "4px",
cursor: "pointer",
};
export const TableWrapper: React.FC<TableComponentProps> = (props) => {
return <TableComponent {...props} />;
};
@@ -0,0 +1,284 @@
"use client";
import React from "react";
import type { TableConfig, TableColumn } from "./types";
/**
* Table ConfigPanel — 통합 데이터 테이블 설정 편집.
*
* displayMode 가 최상위. 나머지는 모드별 세부 옵션.
* Phase C-1 최소 구현; Phase F 에서 FieldConfig 연결 / SQL query builder /
* 고급 필터 등 확장.
*/
export interface TableConfigPanelProps {
config?: TableConfig;
onChange?: (config: TableConfig) => void;
selectedComponent?: { id: string; config?: TableConfig; [k: string]: any };
}
export const TableConfigPanel: React.FC<TableConfigPanelProps> = ({
config,
onChange,
selectedComponent,
}) => {
const current: TableConfig =
(config as TableConfig) || (selectedComponent?.config as TableConfig) || {};
const patch = (p: Partial<TableConfig>) => {
onChange?.({ ...current, ...p });
};
const columns: TableColumn[] = current.columns ?? [];
const updateColumn = (idx: number, col: Partial<TableColumn>) => {
const next = columns.map((c, i) => (i === idx ? { ...c, ...col } : c));
patch({ columns: next });
};
const addColumn = () => {
patch({
columns: [
...columns,
{
key: `col${columns.length + 1}`,
label: `컬럼 ${columns.length + 1}`,
},
],
});
};
const removeColumn = (idx: number) => {
patch({ columns: columns.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>
<select
value={current.displayMode || "table"}
onChange={(e) =>
patch({ displayMode: e.target.value as TableConfig["displayMode"] })
}
className="border-border bg-background w-full rounded border px-2 py-1 text-xs"
>
<option value="table"> </option>
<option value="split"> ( · )</option>
<option value="grouped"></option>
<option value="pivot"></option>
<option value="card"> </option>
</select>
</div>
<div>
<label className="text-muted-foreground mb-1 block text-[0.62rem] font-semibold tracking-wider uppercase">
</label>
<select
value={current.selectionMode || "single"}
onChange={(e) =>
patch({
selectionMode: e.target.value as TableConfig["selectionMode"],
})
}
className="border-border bg-background w-full rounded border px-2 py-1 text-xs"
>
<option value="none"> </option>
<option value="single"> </option>
<option value="multiple"> </option>
</select>
</div>
<div>
<label className="text-muted-foreground mb-1 block text-[0.62rem] font-semibold tracking-wider uppercase">
</label>
<select
value={current.rowHeight || "normal"}
onChange={(e) =>
patch({ rowHeight: e.target.value as TableConfig["rowHeight"] })
}
className="border-border bg-background w-full rounded border px-2 py-1 text-xs"
>
<option value="compact"></option>
<option value="normal"></option>
<option value="relaxed"></option>
</select>
</div>
<div className="flex flex-col gap-1.5">
<label className="flex items-center gap-2 text-xs">
<input
type="checkbox"
checked={current.showHeader !== false}
onChange={(e) => patch({ showHeader: e.target.checked })}
/>
<span> </span>
</label>
<label className="flex items-center gap-2 text-xs">
<input
type="checkbox"
checked={current.showFooter !== false}
onChange={(e) => patch({ showFooter: e.target.checked })}
/>
<span>/</span>
</label>
<label className="flex items-center gap-2 text-xs">
<input
type="checkbox"
checked={!!current.showCheckbox}
onChange={(e) => patch({ showCheckbox: e.target.checked })}
/>
<span> </span>
</label>
<label className="flex items-center gap-2 text-xs">
<input
type="checkbox"
checked={current.striped ?? true}
onChange={(e) => patch({ striped: e.target.checked })}
/>
<span> ( )</span>
</label>
<label className="flex items-center gap-2 text-xs">
<input
type="checkbox"
checked={current.hoverable ?? true}
onChange={(e) => patch({ hoverable: e.target.checked })}
/>
<span> </span>
</label>
<label className="flex items-center gap-2 text-xs">
<input
type="checkbox"
checked={current.showToolbar ?? true}
onChange={(e) => patch({ showToolbar: e.target.checked })}
/>
<span> </span>
</label>
</div>
{current.displayMode === "split" && (
<div className="border-border border-t pt-2">
<label className="text-muted-foreground mb-1 block text-[0.62rem] font-semibold tracking-wider uppercase">
(0.1 ~ 0.9)
</label>
<input
type="number"
value={current.splitRatio ?? 0.5}
min={0.1}
max={0.9}
step={0.05}
onChange={(e) => patch({ splitRatio: Number(e.target.value) })}
className="border-border bg-background w-full rounded border px-2 py-1 text-xs"
/>
</div>
)}
{current.displayMode === "grouped" && (
<div className="border-border border-t pt-2">
<label className="text-muted-foreground mb-1 block text-[0.62rem] font-semibold tracking-wider uppercase">
</label>
<input
type="text"
value={current.groupBy || ""}
onChange={(e) => patch({ groupBy: e.target.value || undefined })}
placeholder="예: department"
className="border-border bg-background w-full rounded border px-2 py-1 text-xs"
/>
</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">
({columns.length})
</span>
<button
type="button"
onClick={addColumn}
className="border-border hover:bg-accent rounded border px-2 py-0.5 text-[0.65rem]"
>
+
</button>
</div>
{columns.length === 0 && (
<div className="text-muted-foreground border-border rounded border border-dashed p-2 text-center text-[0.6rem]">
. Phase F FieldConfig .
</div>
)}
{columns.map((col, 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={() => removeColumn(idx)}
className="text-destructive text-[0.6rem] hover:underline"
>
</button>
</div>
<div className="flex flex-col gap-1.5">
<input
type="text"
value={col.key}
onChange={(e) => updateColumn(idx, { key: e.target.value })}
placeholder="컬럼 키"
className="border-border bg-background w-full rounded border px-1.5 py-0.5 text-[0.65rem] font-mono"
/>
<input
type="text"
value={col.label}
onChange={(e) => updateColumn(idx, { label: 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-3 gap-1.5">
<input
type="number"
value={col.width ?? ""}
onChange={(e) =>
updateColumn(idx, {
width: e.target.value === "" ? undefined : Number(e.target.value),
})
}
placeholder="너비"
className="border-border bg-background w-full rounded border px-1.5 py-0.5 text-[0.65rem]"
/>
<select
value={col.align || "left"}
onChange={(e) =>
updateColumn(idx, { align: e.target.value as TableColumn["align"] })
}
className="border-border bg-background w-full rounded border px-1.5 py-0.5 text-[0.65rem]"
>
<option value="left"></option>
<option value="center"></option>
<option value="right"></option>
</select>
<label className="flex items-center gap-1 text-[0.6rem]">
<input
type="checkbox"
checked={col.sortable ?? true}
onChange={(e) => updateColumn(idx, { sortable: e.target.checked })}
/>
<span></span>
</label>
</div>
</div>
</div>
))}
</div>
</div>
);
};
export default TableConfigPanel;
@@ -0,0 +1,27 @@
"use client";
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { TableDefinition } from "./index";
import { TableComponent } from "./TableComponent";
/**
* Table 렌더러
*
* AutoRegisteringComponentRenderer 상속으로 자동 등록.
*/
export class TableRenderer extends AutoRegisteringComponentRenderer {
static componentDefinition = TableDefinition;
render(): React.ReactElement {
return <TableComponent {...this.props} renderer={this} />;
}
}
// 자동 등록
TableRenderer.registerSelf();
// Hot reload (dev)
if (process.env.NODE_ENV === "development") {
TableRenderer.enableHotReload();
}
@@ -0,0 +1,74 @@
"use client";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import { TableWrapper } from "./TableComponent";
import { TableConfigPanel } from "./TableConfigPanel";
import type { TableConfig } from "./types";
/**
* Table — 통합 데이터 테이블 컴포넌트 (2026-04-11, Phase C-1)
*
* 흡수 대상 (9):
* - v2-table-list (base)
* - v2-table-grouped (displayMode='grouped')
* - v2-pivot-grid (displayMode='pivot')
* - v2-split-panel-layout (displayMode='split')
* - table-list, split-panel-layout, split-panel-layout2 (legacy)
* - modal-repeater-table, simple-repeater-table (legacy)
* - pivot-grid, tax-invoice-list (legacy)
*
* 관련 문서:
* notes/gbpark/2026-04-11-component-unification-plan.md §3.1
*/
const DEFAULT_CONFIG: Partial<TableConfig> = {
displayMode: "table",
selectionMode: "single",
showCheckbox: false,
showHeader: true,
showFooter: true,
showToolbar: true,
striped: true,
hoverable: true,
rowHeight: "normal",
pagination: {
enabled: true,
pageSize: 20,
},
};
export const TableDefinition = createComponentDefinition({
id: "table",
name: "테이블",
name_eng: "Table",
description:
"통합 데이터 테이블. 기본/분할/그룹/피벗/카드 5가지 모드 지원",
category: ComponentCategory.DISPLAY,
web_type: "text",
component: TableWrapper,
default_config: DEFAULT_CONFIG as Record<string, any>,
default_size: { width: 800, height: 400 },
config_panel: TableConfigPanel,
icon: "Table",
tags: ["테이블", "table", "grid", "list", "data", "split", "pivot"],
version: "2.0.0",
author: "INVYONE",
documentation:
"notes/gbpark/2026-04-11-component-unification-plan.md#31-table",
// ─── INVYONE DataPort 선언 ───
dataPorts: {
inputs: [
{ name: "searchParams", type: "params" },
{ name: "refreshTrigger", type: "value" },
],
outputs: [
{ name: "selectedRow", type: "row" },
{ name: "selectedRows", type: "rows" },
],
},
});
export type { TableConfig, TableColumn } from "./types";
export { TableComponent, TableWrapper } from "./TableComponent";
export { TableConfigPanel } from "./TableConfigPanel";
@@ -0,0 +1,103 @@
"use client";
import { ComponentConfig } from "@/types/component";
import type { FieldConfig } from "@/types/invyone-component";
/**
* Table 컴포넌트 통합 설정 타입
*
* 9개의 기존 테이블 계열 컴포넌트를 통합한 **범용 데이터 테이블**.
* displayMode 로 변형 (기본/분할/그룹/피벗).
*
* 흡수 대상 (9):
* - v2-table-list (base, 기본 테이블)
* - v2-table-grouped (그룹핑 테이블)
* - v2-pivot-grid (피벗 그리드)
* - v2-split-panel-layout (좌우 분할: 목록 | 상세)
* - table-list, split-panel-layout, split-panel-layout2 (legacy)
* - modal-repeater-table, simple-repeater-table (legacy)
* - tax-invoice-list, pivot-grid (legacy)
*/
export type TableDisplayMode = "table" | "split" | "grouped" | "pivot" | "card";
export type TableSelectionMode = "none" | "single" | "multiple";
export type TableRowHeight = "compact" | "normal" | "relaxed";
export interface TableColumn {
/** 컬럼 키 (데이터 필드명) */
key: string;
/** 컬럼 라벨 */
label: string;
/** 너비 (px) */
width?: number;
/** 정렬 */
align?: "left" | "center" | "right";
/** 정렬 가능 여부 */
sortable?: boolean;
/** 포맷 */
format?: string;
/** 표시 여부 */
visible?: boolean;
}
export interface TablePagination {
enabled?: boolean;
pageSize?: number;
showSizeSelector?: boolean;
pageSizeOptions?: number[];
}
export interface TableConfig extends ComponentConfig {
/** 표시 모드 (기본/분할/그룹/피벗/카드) */
displayMode?: TableDisplayMode;
/** 컬럼 설정 */
columns?: TableColumn[];
/** FieldConfig 기반 컬럼 자동 생성 (선택) */
fields?: FieldConfig[];
/** 행 선택 모드 */
selectionMode?: TableSelectionMode;
/** 체크박스 컬럼 */
showCheckbox?: boolean;
/** 헤더 표시 */
showHeader?: boolean;
/** 푸터 표시 */
showFooter?: boolean;
/** 페이지네이션 */
pagination?: TablePagination;
/** 행 높이 */
rowHeight?: TableRowHeight;
/** 줄무늬 (홀수/짝수 다른 색) */
striped?: boolean;
/** 호버 효과 */
hoverable?: boolean;
/** 테두리 */
bordered?: boolean;
// ─── split 모드 전용 ───
/** split 모드: 좌측 패널 너비 비율 (0~1) */
splitRatio?: number;
// ─── grouped 모드 전용 ───
/** grouped 모드: 그룹화할 컬럼 키 */
groupBy?: string;
// ─── pivot 모드 전용 ───
/** pivot 모드: row 차원 */
pivotRows?: string[];
/** pivot 모드: column 차원 */
pivotColumns?: string[];
/** pivot 모드: 값 집계 */
pivotValues?: { column: string; aggregation: "sum" | "count" | "avg" | "min" | "max" }[];
// ─── 빈 상태 / 로딩 ───
/** 빈 상태 메시지 */
emptyMessage?: string;
// ─── 툴바 ───
/** 툴바 표시 */
showToolbar?: boolean;
/** 엑셀 내보내기 버튼 */
showExcel?: boolean;
/** 새로고침 버튼 */
showRefresh?: boolean;
}
@@ -0,0 +1,222 @@
"use client";
import React from "react";
import { ComponentRendererProps } from "@/types/component";
import { TitleConfig, TitleVariant } from "./types";
import { getAdaptiveLabelColor } from "@/lib/utils/darkModeColor";
/**
* Title — 통합 제목/텍스트 컴포넌트
*
* 기존 v2-text-display + text-display(legacy) 를 흡수. variant 로 시맨틱
* 프리셋(h1~h6/body/caption) 을 제공하며, 세부 스타일은 config 로 override.
*/
export interface TitleComponentProps extends ComponentRendererProps {
config?: TitleConfig;
}
// variant 별 기본 스타일 (config 로 override 가능)
const VARIANT_PRESETS: Record<
TitleVariant,
{ fontSize: string; fontWeight: string }
> = {
h1: { fontSize: "2rem", fontWeight: "700" },
h2: { fontSize: "1.5rem", fontWeight: "700" },
h3: { fontSize: "1.25rem", fontWeight: "600" },
h4: { fontSize: "1.1rem", fontWeight: "600" },
h5: { fontSize: "1rem", fontWeight: "600" },
h6: { fontSize: "0.875rem", fontWeight: "600" },
body: { fontSize: "14px", fontWeight: "normal" },
caption: { fontSize: "12px", fontWeight: "normal" },
};
export const TitleComponent: React.FC<TitleComponentProps> = ({
component,
isDesignMode = false,
isSelected = false,
onClick,
onDragStart,
onDragEnd,
config,
className,
style,
...props
}) => {
// DynamicComponentRenderer.tsx:882 가 mergedComponentConfig 를 top-level
// props 로 spread 해서 전달. 즉 text/fontSize/color 등이 props 에 직접 있음.
// 4곳 전부 머지해서 어느 경로든 반영되게 한다.
const fromProps: Partial<TitleConfig> = {};
const p = props as any;
if (p.text !== undefined) fromProps.text = p.text;
if (p.variant !== undefined) fromProps.variant = p.variant;
if (p.fontSize !== undefined) fromProps.fontSize = p.fontSize;
if (p.fontWeight !== undefined) fromProps.fontWeight = p.fontWeight;
if (p.color !== undefined) fromProps.color = p.color;
if (p.textAlign !== undefined) fromProps.textAlign = p.textAlign;
if (p.lineHeight !== undefined) fromProps.lineHeight = p.lineHeight;
if (p.letterSpacing !== undefined) fromProps.letterSpacing = p.letterSpacing;
if (p.truncate !== undefined) fromProps.truncate = p.truncate;
const componentConfig = {
...config,
...((component as any).config ?? {}),
...((component as any).componentConfig ?? {}),
...fromProps, // top-level spread props 가 최종 우선
} as TitleConfig;
const variant = componentConfig.variant;
const preset = variant ? VARIANT_PRESETS[variant] : undefined;
const text = componentConfig.text ?? "텍스트를 입력하세요";
const fontSize = componentConfig.fontSize ?? preset?.fontSize ?? "14px";
const fontWeight = componentConfig.fontWeight ?? preset?.fontWeight ?? "normal";
const color = componentConfig.color ?? "hsl(var(--foreground))";
const textAlign = componentConfig.textAlign ?? "left";
const lineHeight = componentConfig.lineHeight ?? "normal";
const letterSpacing = componentConfig.letterSpacing ?? "0";
const truncate = componentConfig.truncate ?? false;
const containerStyle: React.CSSProperties = {
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent:
textAlign === "center"
? "center"
: textAlign === "right"
? "flex-end"
: "flex-start",
padding: "2px 4px",
boxSizing: "border-box",
position: "relative",
...(component as any).style,
...style,
};
if (isDesignMode) {
containerStyle.border = `1px dashed ${isSelected ? "hsl(var(--primary))" : "hsl(var(--border))"}`;
}
const textStyle: React.CSSProperties = {
fontSize,
fontWeight,
color: getAdaptiveLabelColor(color) ?? color,
textAlign,
lineHeight,
letterSpacing,
whiteSpace: truncate ? "nowrap" : "normal",
overflow: truncate ? "hidden" : "visible",
textOverflow: truncate ? "ellipsis" : "clip",
width: "100%",
margin: 0,
};
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
onClick?.();
};
// DOM 에 전달하면 안 되는 React-specific props 필터링
/* eslint-disable @typescript-eslint/no-unused-vars */
const {
selectedScreen: _1,
onZoneComponentDrop: _2,
onZoneClick: _3,
componentConfig: _4,
component: _5,
isSelected: _6,
onClick: _7,
onDragStart: _8,
onDragEnd: _9,
size: _10,
position: _11,
style: _12,
screenId: _13,
tableName: _14,
onRefresh: _15,
onClose: _16,
web_type: _17,
autoGeneration: _18,
isInteractive: _19,
formData: _20,
onFormDataChange: _21,
menuId: _22,
menuObjid: _23,
onSave: _24,
userId: _25,
userName: _26,
companyCode: _27,
isInModal: _28,
readonly: _29,
originalData: _30,
_originalData: _31,
_initialData: _32,
_groupedData: _33,
allComponents: _34,
onUpdateLayout: _35,
selectedRows: _36,
selectedRowsData: _37,
onSelectedRowsChange: _38,
sortBy: _39,
sortOrder: _40,
tableDisplayData: _41,
flowSelectedData: _42,
flowSelectedStepId: _43,
onFlowSelectedDataChange: _44,
onConfigChange: _45,
refreshKey: _46,
flowRefreshKey: _47,
onFlowRefresh: _48,
isPreview: _49,
groupedData: _50,
// ★ TitleConfig 필드 — DOM 에 spread 되면 React warning. 제외.
text: _51,
variant: _52,
fontSize: _53,
fontWeight: _54,
color: _55,
textAlign: _56,
lineHeight: _57,
letterSpacing: _58,
truncate: _59,
defaultValue: _60,
placeholder: _61,
// 기타 noise
maxLength: _62,
minLength: _63,
disabled: _64,
required: _65,
helperText: _66,
readonly: _67,
...domProps
} = props as any;
/* eslint-enable @typescript-eslint/no-unused-vars */
// variant 가 h1~h6 이면 시맨틱 태그, 아니면 div.
// React.createElement 로 생성해서 JSX.IntrinsicElements 네임스페이스 의존 회피.
const tagName = variant && /^h[1-6]$/.test(variant) ? variant : "div";
const textElement = React.createElement(
tagName,
{ style: textStyle },
text,
);
return (
<div
style={containerStyle}
className={className}
onClick={handleClick}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
{...domProps}
>
{textElement}
</div>
);
};
export const TitleWrapper: React.FC<TitleComponentProps> = (props) => {
return <TitleComponent {...props} />;
};
@@ -0,0 +1,151 @@
"use client";
import React from "react";
import type { TitleConfig } from "./types";
/**
* Title ConfigPanel — V2PropertiesPanel 에서 호출되는 간단한 설정 패널.
*
* text / variant / fontSize / fontWeight / color / textAlign / truncate 를 편집.
* Phase A-2 의 최소 구현; Phase F(정교화) 에서 시맨틱 variant 프리셋 UI 확장 예정.
*/
export interface TitleConfigPanelProps {
config?: TitleConfig;
onChange?: (config: TitleConfig) => void;
onUpdateProperty?: (componentId: string, path: string, value: unknown) => void;
selectedComponent?: { id: string; config?: TitleConfig; [k: string]: any };
}
export const TitleConfigPanel: React.FC<TitleConfigPanelProps> = ({
config,
onChange,
onUpdateProperty,
selectedComponent,
}) => {
const current: TitleConfig =
(config as TitleConfig) || (selectedComponent?.config as TitleConfig) || {};
const patch = (p: Partial<TitleConfig>) => {
const next = { ...current, ...p };
onChange?.(next);
if (selectedComponent?.id) {
Object.entries(p).forEach(([key, value]) => {
onUpdateProperty?.(selectedComponent.id, `config.${key}`, value);
});
}
};
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.text || ""}
onChange={(e) => patch({ text: e.target.value })}
placeholder="텍스트를 입력하세요"
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.variant || ""}
onChange={(e) =>
patch({
variant: (e.target.value || undefined) as TitleConfig["variant"],
})
}
className="border-border bg-background w-full rounded border px-2 py-1 text-xs"
>
<option value="">()</option>
<option value="h1"> 1 (H1)</option>
<option value="h2"> 2 (H2)</option>
<option value="h3"> 3 (H3)</option>
<option value="h4"> 4 (H4)</option>
<option value="h5"> 5 (H5)</option>
<option value="h6"> 6 (H6)</option>
<option value="body"></option>
<option value="caption"></option>
</select>
</div>
<div>
<label className="text-muted-foreground mb-1 block text-[0.62rem] font-semibold tracking-wider uppercase">
(override)
</label>
<input
type="text"
value={current.fontSize || ""}
onChange={(e) => patch({ fontSize: e.target.value || undefined })}
placeholder="14px"
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.fontWeight || "normal"}
onChange={(e) => patch({ fontWeight: e.target.value })}
className="border-border bg-background w-full rounded border px-2 py-1 text-xs"
>
<option value="normal">normal</option>
<option value="500">500 (medium)</option>
<option value="600">600 (semibold)</option>
<option value="700">700 (bold)</option>
<option value="800">800 (extrabold)</option>
</select>
</div>
<div>
<label className="text-muted-foreground mb-1 block text-[0.62rem] font-semibold tracking-wider uppercase">
</label>
<input
type="color"
value={current.color || "#212121"}
onChange={(e) => patch({ color: e.target.value })}
className="border-border bg-background h-7 w-full rounded border px-1"
/>
</div>
<div>
<label className="text-muted-foreground mb-1 block text-[0.62rem] font-semibold tracking-wider uppercase">
</label>
<select
value={current.textAlign || "left"}
onChange={(e) =>
patch({ textAlign: e.target.value as TitleConfig["textAlign"] })
}
className="border-border bg-background w-full rounded border px-2 py-1 text-xs"
>
<option value="left"></option>
<option value="center"></option>
<option value="right"></option>
<option value="justify"> </option>
</select>
</div>
<label className="flex items-center gap-2 text-xs">
<input
type="checkbox"
checked={!!current.truncate}
onChange={(e) => patch({ truncate: e.target.checked })}
/>
<span> ()</span>
</label>
</div>
);
};
export default TitleConfigPanel;
@@ -0,0 +1,29 @@
"use client";
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { TitleDefinition } from "./index";
import { TitleComponent } from "./TitleComponent";
/**
* Title 렌더러
*
* AutoRegisteringComponentRenderer 를 상속하여 import 시점에 자동으로
* ComponentRegistry 에 등록된다. components/index.ts 에서 이 파일을 import
* 해야 등록이 실행됨.
*/
export class TitleRenderer extends AutoRegisteringComponentRenderer {
static componentDefinition = TitleDefinition;
render(): React.ReactElement {
return <TitleComponent {...this.props} renderer={this} />;
}
}
// 자동 등록
TitleRenderer.registerSelf();
// Hot reload (dev)
if (process.env.NODE_ENV === "development") {
TitleRenderer.enableHotReload();
}
@@ -0,0 +1,55 @@
"use client";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import { TitleWrapper } from "./TitleComponent";
import { TitleConfigPanel } from "./TitleConfigPanel";
import type { TitleConfig } from "./types";
/**
* Title — 통합 제목/텍스트 컴포넌트 정의 (2026-04-11, Phase A-2)
*
* 흡수 대상:
* - v2-text-display (DISPLAY, withContainerQuery 적용)
* - text-display (legacy, 완전 중복)
*
* 변형:
* - variant: h1~h6 | body | caption (시맨틱 프리셋)
* - fontSize / fontWeight / color / textAlign 개별 override
* - truncate (말줄임)
*
* 관련 문서:
* notes/gbpark/2026-04-11-component-unification-plan.md §3.7
*/
const DEFAULT_CONFIG: Partial<TitleConfig> = {
text: "텍스트를 입력하세요",
fontSize: "14px",
fontWeight: "normal",
color: "#212121",
textAlign: "left",
};
export const TitleDefinition = createComponentDefinition({
id: "title",
name: "제목/텍스트",
name_eng: "Title",
description:
"제목/본문/캡션 통합 텍스트 컴포넌트. variant 프리셋 + 개별 스타일",
category: ComponentCategory.DISPLAY,
web_type: "text",
component: TitleWrapper,
default_config: DEFAULT_CONFIG as Record<string, any>,
default_size: { width: 200, height: 28 },
config_panel: TitleConfigPanel,
icon: "Type",
tags: ["제목", "텍스트", "title", "text", "label", "heading"],
version: "2.0.0",
author: "INVYONE",
documentation:
"notes/gbpark/2026-04-11-component-unification-plan.md#37-title",
});
export type { TitleConfig } from "./types";
export { TitleComponent, TitleWrapper } from "./TitleComponent";
export { TitleConfigPanel } from "./TitleConfigPanel";
@@ -0,0 +1,43 @@
"use client";
import { ComponentConfig } from "@/types/component";
/**
* Title 컴포넌트 통합 설정 타입
*
* v2-text-display + text-display(legacy) 를 흡수한 통합 규격.
* 제목/텍스트 표시용. fontSize / fontWeight / color / textAlign 을 제공하며
* variant 로 h1~h6 / body / caption 의 시맨틱 프리셋을 제공한다.
*/
export type TitleTextAlign = "left" | "center" | "right" | "justify";
export type TitleVariant =
| "h1"
| "h2"
| "h3"
| "h4"
| "h5"
| "h6"
| "body"
| "caption";
export interface TitleConfig extends ComponentConfig {
/** 표시 텍스트. 기본 '텍스트를 입력하세요'. */
text?: string;
/** 시맨틱 프리셋 (h1/body 등). 설정 시 fontSize/fontWeight 자동 보정. */
variant?: TitleVariant;
/** 폰트 크기 (CSS). 기본 '14px'. variant 가 있으면 override. */
fontSize?: string;
/** 폰트 두께 (CSS). 기본 'normal'. */
fontWeight?: string;
/** 텍스트 색상 (CSS). 기본 '#212121'. */
color?: string;
/** 정렬. 기본 'left'. */
textAlign?: TitleTextAlign;
/** 줄 간격. 기본 'normal'. */
lineHeight?: string;
/** 글자 간격. 기본 '0'. */
letterSpacing?: string;
/** 말줄임 여부. 기본 false. */
truncate?: boolean;
}
@@ -51,6 +51,17 @@ export const V2ButtonPrimaryDefinition = createComponentDefinition({
version: "1.0.0",
author: "개발팀",
documentation: "https://docs.example.com/components/button-primary",
// ─── INVYONE DataPort 선언 ───
dataPorts: {
inputs: [
{ name: "disabled", type: "value" },
{ name: "formData", type: "row" },
],
outputs: [{ name: "clicked", type: "value" }],
},
// ★ 2026-04-11: 통합 `button` 컴포넌트로 흡수됨. 팔레트에서 숨김.
// 기존 저장 화면의 v2-button-primary 는 계속 렌더되지만, 신규 배치는 `button`.
hidden: true,
});
// 컴포넌트는 ButtonPrimaryRenderer에서 자동 등록됩니다
@@ -31,6 +31,9 @@ export const V2DividerLineDefinition = createComponentDefinition({
version: "1.0.0",
author: "Developer",
documentation: "https://docs.example.com/components/divider-line",
// ★ 2026-04-11: 통합 `divider` 컴포넌트로 흡수됨. 팔레트에서 숨김.
// 기존 저장 화면의 v2-divider-line 은 계속 렌더되지만, 신규 배치는 `divider`.
hidden: true,
});
// 타입 내보내기
@@ -29,6 +29,15 @@ export const V2InputDefinition = createComponentDefinition({
// 설정 패널
config_panel: V2FieldConfigPanel,
// ─── INVYONE DataPort 선언 ───
dataPorts: {
inputs: [{ name: "value", type: "value" }],
outputs: [
{ name: "value", type: "value" },
{ name: "changed", type: "value" },
],
},
});
export default V2InputDefinition;
@@ -31,6 +31,10 @@ export const V2SplitLineDefinition = createComponentDefinition({
version: "1.0.0",
author: "개발팀",
documentation: "",
// ★ 2026-04-11: 통합 `divider` 로 흡수 예정. 팔레트에서 숨김.
// drag-resize 기능은 다음 Phase 에서 divider 의 vertical+resizable 옵션으로
// 복구 예정. 기존 저장 화면은 계속 작동.
hidden: true,
});
// 타입 내보내기
@@ -1,7 +1,9 @@
"use client";
import React, { useEffect, useRef, useState } from "react";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { TableListWrapper } from "./TableListComponent";
import { fieldsToColumns } from "@/lib/fieldConfig/adapters";
import type { FieldConfig } from "@/types/invyone-component";
/**
* v2-table-list 반응형 래퍼 (2026-04-10, Phase 1 Step 6)
@@ -14,10 +16,15 @@ import { TableListWrapper } from "./TableListComponent";
* - width < NARROW_BREAKPOINT → narrow (기존 CardModeRenderer)
*
* container-type: inline-size 는 향후 다른 @container 쿼리 조합에도 쓰도록 부착.
*
* ─── INVYONE FieldConfig 경로 (Phase 1+) ────────────────────────────────
* props.fields: FieldConfig[] 이 있으면 화면 수준에서 정의된 단일 필드 규격을
* 컬럼 설정으로 자동 변환해 기존 config.columns 를 덮어쓴다. 없으면 기존 경로
* (config.columns) 그대로. 두 경로가 공존하므로 기존 화면은 수정 없이 작동.
*/
const NARROW_BREAKPOINT = 600;
type AnyProps = Record<string, any>;
type AnyProps = Record<string, any> & { fields?: FieldConfig[] };
export const TableListContainerWrapper: React.FC<AnyProps> = (props) => {
const rootRef = useRef<HTMLDivElement | null>(null);
@@ -45,11 +52,23 @@ export const TableListContainerWrapper: React.FC<AnyProps> = (props) => {
return () => ro.disconnect();
}, []);
const originalConfig = (props?.config ?? {}) as AnyProps;
const effectiveConfig: AnyProps =
mode === "narrow"
? { ...originalConfig, displayMode: "card" }
: originalConfig;
// INVYONE FieldConfig 를 기존 columns 포맷으로 메모이즈 변환.
// fields 가 없거나 빈 배열이면 null → 기존 config.columns 경로 유지.
const derivedColumns = useMemo(() => {
if (!Array.isArray(props.fields) || props.fields.length === 0) return null;
return fieldsToColumns(props.fields);
}, [props.fields]);
const { fields: _fields, ...restProps } = props ?? ({} as AnyProps);
const originalConfig = (restProps?.config ?? {}) as Record<string, any>;
const effectiveConfig: Record<string, any> = (() => {
const base =
mode === "narrow"
? { ...originalConfig, displayMode: "card" }
: originalConfig;
return derivedColumns ? { ...base, columns: derivedColumns } : base;
})();
return (
<div
@@ -62,7 +81,10 @@ export const TableListContainerWrapper: React.FC<AnyProps> = (props) => {
height: "100%",
}}
>
<TableListWrapper {...(props as any)} config={effectiveConfig as any} />
<TableListWrapper
{...(restProps as any)}
config={effectiveConfig as any}
/>
</div>
);
};
@@ -115,6 +115,17 @@ export const V2TableListDefinition = createComponentDefinition({
version: "1.0.0",
author: "개발팀",
documentation: "https://docs.example.com/components/table-list",
// ─── INVYONE DataPort 선언 ───
dataPorts: {
inputs: [
{ name: "searchParams", type: "params" },
{ name: "refreshTrigger", type: "value" },
],
outputs: [
{ name: "selectedRow", type: "row" },
{ name: "selectedRows", type: "rows" },
],
},
});
// 컴포넌트는 TableListRenderer에서 자동 등록됩니다
@@ -22,7 +22,14 @@ ComponentRegistry.registerComponent({
config_panel: V2TableSearchWidgetConfigPanel,
version: "1.0.0",
author: "Invyone",
});
// ─── INVYONE DataPort 선언 ───
dataPorts: {
outputs: [{ name: "searchParams", type: "params" }],
},
// ★ 2026-04-11: 통합 `search` 컴포넌트로 흡수됨. 팔레트에서 숨김.
// 기존 저장 화면의 v2-table-search-widget 은 계속 렌더되지만, 신규 배치는 `search`.
hidden: true,
} as any);
export { TableSearchWidget } from "./TableSearchWidget";
export { TableSearchWidgetRenderer } from "./TableSearchWidgetRenderer";
@@ -35,6 +35,9 @@ export const V2TextDisplayDefinition = createComponentDefinition({
version: "1.0.0",
author: "개발팀",
documentation: "https://docs.example.com/components/text-display",
// ★ 2026-04-11: 통합 `title` 컴포넌트로 흡수됨. 팔레트에서 숨김.
// 기존 저장 화면의 v2-text-display 는 계속 렌더되지만, 신규 배치는 `title`.
hidden: true,
});
// 타입 내보내기
@@ -34,6 +34,7 @@ export function createComponentDefinition(options: CreateComponentDefinitionOpti
validation,
dependencies = [],
hidden,
dataPorts,
} = options;
// 기본 검증
@@ -90,6 +91,7 @@ export function createComponentDefinition(options: CreateComponentDefinitionOpti
validation,
dependencies,
hidden,
dataPorts,
created_at: new Date(),
};
@@ -236,6 +238,7 @@ export function cloneComponentDefinition(
validation: original.validation,
dependencies: original.dependencies,
hidden: original.hidden,
dataPorts: original.dataPorts,
...overrides,
});
}
@@ -6,7 +6,22 @@ import React from "react";
import type { ConfigPanelContext } from "@/lib/registry/components/common/ConfigPanelTypes";
// 컴포넌트별 ConfigPanel 동적 import 맵
//
// ★ 2026-04-11: INVYONE 통합 컴포넌트는 여기에 명시 등록 필요.
// `ComponentDefinition.config_panel` 필드는 이 파일에서 읽지 않음.
// Phase 마다 새 통합 컴포넌트 추가 시 아래 "INVYONE 통합" 섹션에 한 줄 추가.
// 관련 문서: notes/gbpark/2026-04-11-component-unification-plan.md §10.5
const CONFIG_PANEL_MAP: Record<string, () => Promise<any>> = {
// ========== INVYONE 통합 컴포넌트 (2026-04-11, Phase A~) ==========
"divider": () => import("@/lib/registry/components/divider/DividerConfigPanel"),
"title": () => import("@/lib/registry/components/title/TitleConfigPanel"),
"button": () => import("@/lib/registry/components/button/ButtonConfigPanel"),
"search": () => import("@/lib/registry/components/search/SearchConfigPanel"),
"input": () => import("@/lib/registry/components/input/InputConfigPanel"),
"stats": () => import("@/lib/registry/components/stats/StatsConfigPanel"),
"table": () => import("@/lib/registry/components/table/TableConfigPanel"),
"container": () => import("@/lib/registry/components/container/ContainerConfigPanel"),
// ========== V2 컴포넌트 ==========
"v2-input": () => import("@/components/v2/config-panels/V2FieldConfigPanel"),
"v2-select": () => import("@/components/v2/config-panels/V2FieldConfigPanel"),
+599
View File
@@ -0,0 +1,599 @@
/* ═══════════════════════════════════════════════════════════════════════════
★ Builder IDE Theme — ScreenDesigner 전용 IDE 톤 오버라이드
═══════════════════════════════════════════════════════════════════════════
목적:
/admin/builder 의 ScreenDesigner 를 기존 v5 Cosmic 톤에서 IDE 톤으로 전환.
JSX / 로직은 건드리지 않고 shadcn HSL 변수만 스코프 안에서 덮어쓴다.
스코프:
.ide-builder 가 붙은 루트 아래에서만 효과.
바깥 (헤더 / 사이드바 / 대시보드 등) 은 Cosmic 톤 그대로 유지.
기준 색 (mockup/css/09-developer.css 와 frontend/styles/developer.css):
다크 #121218 / #1a1a22 / #22222c / #3a3a48 / #e8e8ee / #5b9ef5
라이트 #f5f5f8 / #ededf2 / #e4e4ec / #d8d8e2 / #1a1a24 / #3b7dd8
═══════════════════════════════════════════════════════════════════════════ */
/* ─── 라이트 모드 ─── */
.ide-builder {
/* 배경 f5f5f8 */
--background: 240 12% 97%;
/* 카드 (패널) ededf2 */
--card: 240 13% 94%;
--card-foreground: 240 17% 12%;
/* 팝오버 ededf2 */
--popover: 240 13% 94%;
--popover-foreground: 240 17% 12%;
/* 전경 1a1a24 */
--foreground: 240 17% 12%;
/* muted e4e4ec / 5a5a6e */
--muted: 240 12% 91%;
--muted-foreground: 240 10% 39%;
/* accent (hover 배경) e4e4ec */
--accent: 240 12% 91%;
--accent-foreground: 240 17% 12%;
/* secondary ededf2 */
--secondary: 240 13% 94%;
--secondary-foreground: 240 17% 12%;
/* 보더 d8d8e2 */
--border: 240 13% 87%;
--input: 240 13% 87%;
/* 액센트 (primary) 3b7dd8 — IDE 블루 */
--primary: 214 66% 54%;
--primary-foreground: 0 0% 100%;
--ring: 214 66% 54%;
/* destructive dc2626 */
--destructive: 0 73% 51%;
--destructive-foreground: 0 0% 100%;
/* 사이드바 (패널 안의 보조 영역) */
--sidebar-background: 240 13% 94%;
--sidebar-foreground: 240 17% 12%;
--sidebar-primary: 214 66% 54%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 12% 91%;
--sidebar-accent-foreground: 240 17% 12%;
--sidebar-border: 240 13% 87%;
--sidebar-ring: 214 66% 54%;
/* 반경을 살짝 더 타이트하게 — IDE 느낌 */
--radius: 0.375rem;
}
/* ─── 다크 모드 ─── */
.dark .ide-builder {
/* 배경 121218 */
--background: 240 13% 9%;
/* 카드 (패널) 1a1a22 */
--card: 240 14% 12%;
--card-foreground: 240 14% 92%;
/* 팝오버 1a1a22 */
--popover: 240 14% 12%;
--popover-foreground: 240 14% 92%;
/* 전경 e8e8ee */
--foreground: 240 14% 92%;
/* muted 22222c / 78788a */
--muted: 240 13% 15%;
--muted-foreground: 240 8% 51%;
/* accent 2a2a36 */
--accent: 240 13% 19%;
--accent-foreground: 240 14% 92%;
/* secondary 1a1a22 */
--secondary: 240 14% 12%;
--secondary-foreground: 240 14% 92%;
/* 보더 3a3a48 */
--border: 240 10% 25%;
--input: 240 10% 25%;
/* 액센트 5b9ef5 — IDE 라이트 블루 */
--primary: 214 89% 66%;
--primary-foreground: 0 0% 100%;
--ring: 214 89% 66%;
/* destructive f87171 */
--destructive: 0 91% 71%;
--destructive-foreground: 0 0% 100%;
/* 사이드바 (패널 안의 보조 영역) */
--sidebar-background: 240 14% 12%;
--sidebar-foreground: 240 14% 92%;
--sidebar-primary: 214 89% 66%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 13% 19%;
--sidebar-accent-foreground: 240 14% 92%;
--sidebar-border: 240 10% 25%;
--sidebar-ring: 214 89% 66%;
}
/* ═══════════════════════════════════════════════════════════════════════════
캔버스 배경 — 도트 그리드 (IDE 특유의 느낌)
기존 ScreenDesigner 의 캔버스 영역에 은은한 도트가 찍힘.
ScreenDesigner 가 캔버스 컨테이너에 특정 클래스를 박고 있지 않으니
.ide-builder 하위의 최상위 bg-background 영역을 폴백으로 커버한다.
실제 캔버스 ref 는 overflow-auto 이므로 내부 inner wrapper 에도 먹게 함.
═══════════════════════════════════════════════════════════════════════════ */
/* 라이트 기본 — 은은한 검정 도트 */
.ide-builder .bg-background {
background-image: radial-gradient(
circle,
rgba(0, 0, 0, 0.05) 0.5px,
transparent 0.5px
);
background-size: 20px 20px;
}
/* 다크 덮어쓰기 — 은은한 흰색 도트 */
.dark .ide-builder .bg-background {
background-image: radial-gradient(
circle,
rgba(255, 255, 255, 0.03) 0.5px,
transparent 0.5px
);
background-size: 20px 20px;
}
/* ═══════════════════════════════════════════════════════════════════════════
★ 컴팩트화 — IDE 특유의 촘촘한 레이아웃
═══════════════════════════════════════════════════════════════════════════
원칙: JSX 는 건드리지 않는다. Tailwind arbitrary 클래스까지 CSS selector
specificity 로 덮어쓴다 (.ide-builder .w-\[300px\] 등).
폰트 기준: 기본 13px → 0.72rem (11.5px) 촘촘. 완전한 mockup 0.42rem 은 읽기
어려워서 웹 최소 가독선인 11~12px 에 맞춤.
═══════════════════════════════════════════════════════════════════════════ */
/* ─── 기본 폰트 살짝 축소 ─── */
.ide-builder {
font-size: 0.8125rem; /* 13px */
line-height: 1.3;
}
/* ─── 패널 너비 축소 (shadcn w-[300px] → 240px) ─── */
.ide-builder .w-\[300px\] {
width: 240px;
}
.ide-builder .w-\[280px\] {
width: 220px;
}
.ide-builder .w-\[320px\] {
width: 250px;
}
/* ─── Tailwind 텍스트 크기 다운그레이드 ─── */
.ide-builder .text-xs {
font-size: 0.625rem;
line-height: 0.9rem;
}
.ide-builder .text-sm {
font-size: 0.72rem;
line-height: 1rem;
}
.ide-builder .text-base {
font-size: 0.8125rem;
line-height: 1.15rem;
}
.ide-builder .text-lg {
font-size: 0.88rem;
line-height: 1.25rem;
}
.ide-builder .text-xl {
font-size: 1rem;
line-height: 1.35rem;
}
/* ─── 헤딩 ─── */
.ide-builder h1 {
font-size: 0.95rem;
}
.ide-builder h2 {
font-size: 0.85rem;
}
.ide-builder h3 {
font-size: 0.78rem;
}
.ide-builder h4,
.ide-builder h5,
.ide-builder h6 {
font-size: 0.72rem;
}
/* ─── 입력/선택 크기 ─── */
.ide-builder input,
.ide-builder select,
.ide-builder textarea {
font-size: 0.72rem;
}
.ide-builder input[type="text"],
.ide-builder input[type="number"],
.ide-builder input[type="search"],
.ide-builder select {
min-height: 26px;
height: 26px;
}
.ide-builder textarea {
min-height: 48px;
}
/* ─── 버튼 ─── */
.ide-builder button {
font-size: 0.72rem;
}
.ide-builder button[data-slot="button"] {
min-height: 26px;
}
/* ─── shadcn Tabs 크기 축소 ─── */
.ide-builder [role="tablist"],
.ide-builder .h-8 {
height: 28px;
min-height: 28px;
}
.ide-builder [role="tab"] {
font-size: 0.68rem;
padding: 0.22rem 0.5rem;
}
/* ─── 패딩 / 간격 다운그레이드 ─── */
.ide-builder .p-4 {
padding: 0.6rem;
}
.ide-builder .p-3 {
padding: 0.45rem;
}
.ide-builder .p-2 {
padding: 0.3rem;
}
.ide-builder .px-4 {
padding-left: 0.7rem;
padding-right: 0.7rem;
}
.ide-builder .px-3 {
padding-left: 0.55rem;
padding-right: 0.55rem;
}
.ide-builder .px-2 {
padding-left: 0.35rem;
padding-right: 0.35rem;
}
.ide-builder .py-3 {
padding-top: 0.4rem;
padding-bottom: 0.4rem;
}
.ide-builder .py-2 {
padding-top: 0.3rem;
padding-bottom: 0.3rem;
}
.ide-builder .py-1 {
padding-top: 0.15rem;
padding-bottom: 0.15rem;
}
.ide-builder .gap-4 {
gap: 0.5rem;
}
.ide-builder .gap-3 {
gap: 0.4rem;
}
.ide-builder .gap-2 {
gap: 0.3rem;
}
.ide-builder .space-y-4 > :not([hidden]) ~ :not([hidden]) {
margin-top: 0.5rem;
}
.ide-builder .space-y-3 > :not([hidden]) ~ :not([hidden]) {
margin-top: 0.4rem;
}
.ide-builder .space-y-2 > :not([hidden]) ~ :not([hidden]) {
margin-top: 0.3rem;
}
/* ─── 아이콘 크기 (lucide) ─── */
.ide-builder svg {
flex-shrink: 0;
}
.ide-builder .size-4 {
width: 0.85rem;
height: 0.85rem;
}
.ide-builder .size-5 {
width: 1rem;
height: 1rem;
}
.ide-builder .size-6 {
width: 1.1rem;
height: 1.1rem;
}
.ide-builder .h-4,
.ide-builder .w-4 {
height: 0.85rem;
width: 0.85rem;
}
/* ─── 라벨 (속성 패널의 Label 컴포넌트) ─── */
.ide-builder label {
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.01em;
}
/* ─── 보더 반경 타이트 ─── */
.ide-builder .rounded-lg {
border-radius: 0.45rem;
}
.ide-builder .rounded-md {
border-radius: 0.35rem;
}
.ide-builder .rounded-sm {
border-radius: 0.25rem;
}
/* ─── 테이블 row 높이 축소 ─── */
.ide-builder table {
font-size: 0.68rem;
}
.ide-builder th,
.ide-builder td {
padding: 0.25rem 0.4rem;
}
/* ─── 카드 (Card) 패딩 축소 ─── */
.ide-builder [data-slot="card"] {
padding: 0;
}
.ide-builder [data-slot="card-header"] {
padding: 0.5rem 0.6rem;
}
.ide-builder [data-slot="card-content"] {
padding: 0.5rem 0.6rem;
}
.ide-builder [data-slot="card-footer"] {
padding: 0.4rem 0.6rem;
}
/* ─── Dialog / Popover 컴팩트 ─── */
.ide-builder [data-slot="popover-content"],
.ide-builder [role="dialog"] {
font-size: 0.72rem;
}
/* ─── Badge 컴팩트 ─── */
.ide-builder [data-slot="badge"] {
font-size: 0.56rem;
padding: 0.08rem 0.3rem;
border-radius: 0.25rem;
}
/* ─── Separator 얇게 ─── */
.ide-builder [data-slot="separator"],
.ide-builder hr {
border-color: hsl(var(--border));
}
/* ─── 포커스 링 은은하게 (IDE 느낌) ─── */
.ide-builder *:focus-visible {
outline: 1.5px solid hsl(var(--ring));
outline-offset: 1px;
box-shadow: none !important;
}
/* ═══════════════════════════════════════════════════════════════════════════
★ 좌측 패널 섹션 (수평 탭 해체 → 수직 아코디언)
═══════════════════════════════════════════════════════════════════════════
기존 <Tabs> 의 수평 탭을 없애고 <details>/<summary> 네이티브 아코디언으로
대체. 한 패널 안에서 컴포넌트/필드/레이어/편집 4개 섹션이 세로로 나열되며
각자 독립적으로 접고 펼 수 있다.
복잡성 축소: "탭 선택 후 내용 봄" (2단계) → "섹션 펼쳐서 내용 봄" (1단계).
═══════════════════════════════════════════════════════════════════════════ */
.ide-builder details.ide-section {
border-bottom: 1px solid hsl(var(--border));
flex-shrink: 0;
min-height: 0;
}
.ide-builder details.ide-section[open] {
display: flex;
flex-direction: column;
min-height: 0;
flex: 1 1 0;
}
.ide-builder details.ide-section > summary.ide-section-header {
list-style: none;
cursor: pointer;
padding: 0.5rem 0.65rem;
font-size: 0.66rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: hsl(var(--primary));
background: hsl(var(--muted) / 0.5);
border-bottom: 1px solid hsl(var(--border));
display: flex;
align-items: center;
gap: 0.4rem;
user-select: none;
transition: background 0.1s;
}
.ide-builder details.ide-section > summary.ide-section-header:hover {
background: hsl(var(--muted));
}
.ide-builder details.ide-section > summary.ide-section-header::-webkit-details-marker {
display: none;
}
.ide-builder details.ide-section > summary.ide-section-header::before {
content: "▶";
font-size: 0.5rem;
color: hsl(var(--muted-foreground));
transition: transform 0.15s;
display: inline-block;
}
.ide-builder details.ide-section[open] > summary.ide-section-header::before {
transform: rotate(90deg);
}
.ide-builder details.ide-section > .ide-section-body {
flex: 1 1 0;
min-height: 0;
max-height: 70vh;
overflow-y: auto;
}
/* ═══════════════════════════════════════════════════════════════════════════
★ v5 Cosmic 토큰 → IDE 블루 오버라이드 (잔존 보라 제거)
═══════════════════════════════════════════════════════════════════════════
v5-layout.css 가 정의한 --v5-primary (#6c5ce7 / #a29bfe) 같은 토큰이 여러
.v5-* 클래스에서 직접 쓰이고 있어서 INVYONE 빌더 안에도 보라가 잔존한다.
.ide-builder 스코프 안에서 이 토큰들을 IDE 블루로 전부 덮어씀.
═══════════════════════════════════════════════════════════════════════════ */
.ide-builder {
/* 라이트 v5 토큰 */
--v5-primary: #3b7dd8;
--v5-primary-light: #5b9ef5;
--v5-primary-glow: rgba(59, 125, 216, 0.18);
--v5-cyan: #0891b2;
--v5-cyan-glow: rgba(8, 145, 178, 0.15);
--v5-pink: #db2777;
--v5-pink-glow: rgba(219, 39, 119, 0.12);
--v5-glass: hsl(240 13% 94% / 0.6);
--v5-glass-strong: hsl(240 13% 94% / 0.8);
--v5-glass-border: rgba(59, 125, 216, 0.15);
--v5-glow-sm: 0 0 0 1px rgba(59, 125, 216, 0.1);
--v5-glow-md: 0 0 12px rgba(59, 125, 216, 0.15);
--v5-glow-lg: 0 0 24px rgba(59, 125, 216, 0.2);
--v5-surface: hsl(240 12% 97%);
}
.dark .ide-builder {
/* 다크 v5 토큰 */
--v5-primary: #5b9ef5;
--v5-primary-light: #93bffc;
--v5-primary-glow: rgba(91, 158, 245, 0.2);
--v5-cyan: #22d3ee;
--v5-cyan-glow: rgba(34, 211, 238, 0.15);
--v5-pink: #ec4899;
--v5-pink-glow: rgba(236, 72, 153, 0.12);
--v5-glass: hsl(240 14% 12% / 0.6);
--v5-glass-strong: hsl(240 14% 12% / 0.8);
--v5-glass-border: rgba(91, 158, 245, 0.15);
--v5-glow-sm: 0 0 0 1px rgba(91, 158, 245, 0.12);
--v5-glow-md: 0 0 12px rgba(91, 158, 245, 0.18);
--v5-glow-lg: 0 0 24px rgba(91, 158, 245, 0.22);
--v5-surface: hsl(240 13% 9%);
}
/* ═══════════════════════════════════════════════════════════════════════════
★ Tailwind primary 유틸리티 강제 덮기 (!important)
═══════════════════════════════════════════════════════════════════════════
Tailwind v4 의 --color-primary 가 런타임 --primary 업데이트를 따라가지
못하는 경우가 있어서 .bg-primary / .text-primary / .border-primary 등을
강제로 덮는다. specificity 는 .ide-builder 스코프로 제한.
═══════════════════════════════════════════════════════════════════════════ */
.ide-builder .bg-primary {
background-color: hsl(var(--primary)) !important;
}
.ide-builder .text-primary {
color: hsl(var(--primary)) !important;
}
.ide-builder .border-primary {
border-color: hsl(var(--primary)) !important;
}
.ide-builder .bg-primary\/5 {
background-color: hsl(var(--primary) / 0.05) !important;
}
.ide-builder .bg-primary\/10 {
background-color: hsl(var(--primary) / 0.1) !important;
}
.ide-builder .bg-primary\/20 {
background-color: hsl(var(--primary) / 0.2) !important;
}
.ide-builder .bg-primary\/90 {
background-color: hsl(var(--primary) / 0.9) !important;
}
.ide-builder .hover\:bg-primary\/90:hover {
background-color: hsl(var(--primary) / 0.9) !important;
}
.ide-builder .hover\:bg-primary\/80:hover {
background-color: hsl(var(--primary) / 0.8) !important;
}
.ide-builder .hover\:bg-primary\/20:hover {
background-color: hsl(var(--primary) / 0.2) !important;
}
.ide-builder .ring-primary {
--tw-ring-color: hsl(var(--primary)) !important;
}
/* shadcn Button default variant 강제 블루 */
.ide-builder [data-slot="button"][data-variant="default"],
.ide-builder button.bg-primary,
.ide-builder [class*="bg-primary"]:not([class*="/"]) {
background-color: hsl(var(--primary)) !important;
color: hsl(var(--primary-foreground)) !important;
}
/* ═══════════════════════════════════════════════════════════════════════════
★ SlimToolbar 슬림화 (h-14 → h-10)
═══════════════════════════════════════════════════════════════════════════ */
.ide-builder .h-14 {
height: 42px !important;
min-height: 42px;
}
.ide-builder .h-14.border-b {
padding-top: 0;
padding-bottom: 0;
}
.ide-builder .h-9 {
height: 28px !important;
min-height: 28px;
}
.ide-builder .text-lg {
font-size: 0.82rem !important;
line-height: 1.15rem;
font-weight: 700;
}
/* ═══════════════════════════════════════════════════════════════════════════
★ INVYONE 브랜드 로고 (SlimToolbar 좌측)
═══════════════════════════════════════════════════════════════════════════ */
.ide-builder .ide-brand {
display: flex;
align-items: center;
gap: 0.4rem;
padding-right: 0.7rem;
margin-right: 0.3rem;
border-right: 1px solid hsl(var(--border));
flex-shrink: 0;
}
.ide-builder .ide-brand-text {
font-size: 0.78rem;
font-weight: 800;
letter-spacing: -0.03em;
color: hsl(var(--primary));
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
}
.ide-builder .ide-brand-badge {
font-size: 0.5rem;
font-weight: 800;
padding: 0.1rem 0.32rem;
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
border-radius: 3px;
letter-spacing: 0.04em;
text-transform: uppercase;
line-height: 1.1;
}
/* ─── 스크롤바 IDE 스타일 ─── */
.ide-builder *::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.ide-builder *::-webkit-scrollbar-track {
background: transparent;
}
.ide-builder *::-webkit-scrollbar-thumb {
background: hsl(var(--border));
border-radius: 5px;
border: 2px solid transparent;
background-clip: padding-box;
}
.ide-builder *::-webkit-scrollbar-thumb:hover {
background: hsl(var(--muted-foreground) / 0.5);
background-clip: padding-box;
border: 2px solid transparent;
}
+15 -1
View File
@@ -4,7 +4,8 @@
*/
import React from "react";
import type { WebType } from "./screen";
import type { WebType, AutoGenerationConfig } from "./screen";
import type { DataPort } from "./invyone-component";
/**
*
@@ -132,6 +133,13 @@ export interface ComponentDefinition {
// 생성 정보
created_at?: Date; // 생성일
updated_at?: Date; // 수정일
// ─── INVYONE 확장 (Phase 1+) ───
/** 컴포넌트 간 통신용 데이터 포트. 빌더에서 시각적 연결 시 사용. */
dataPorts?: {
inputs?: DataPort[];
outputs?: DataPort[];
};
}
/**
@@ -280,6 +288,12 @@ export interface CreateComponentDefinitionOptions {
validation?: ComponentValidation;
dependencies?: string[];
hidden?: boolean; // 팔레트에서 숨김 여부
// ─── INVYONE 확장 ───
/** 컴포넌트 간 통신용 데이터 포트 선언 (Phase 1+) */
dataPorts?: {
inputs?: DataPort[];
outputs?: DataPort[];
};
}
/**
+9 -2
View File
@@ -18,6 +18,7 @@ import {
isWebType,
} from "./v2-core";
import { ColumnSpanPreset } from "@/lib/constants/columnSpans";
import type { FieldConfig, Connection } from "./invyone-component";
import { ResponsiveComponentConfig } from "./responsive";
// ===== 기본 컴포넌트 인터페이스 =====
@@ -786,6 +787,12 @@ export interface ScreenDefinition {
rest_api_connection_id?: number;
rest_api_endpoint?: string;
rest_api_json_path?: string;
// ─── INVYONE 확장 (Phase 1+) ───
/** 화면 수준 필드 규격 — 테이블/폼/검색 컴포넌트가 공유하는 단일 필드 정의 */
fields?: FieldConfig[];
/** 컴포넌트 간 DataPort 연결 목록 (런타임에 DataPortBus 브리지로 변환) */
connections?: Connection[];
}
/**
@@ -1053,8 +1060,8 @@ export const isWidgetComponent = (component: ComponentData): component is Widget
return false;
}
// widgetType이 유효한 WebType인지 체크
if (!component.widgetType || !isWebType(component.widgetType)) {
// widget_type 이 유효한 WebType 인지 체크
if (!component.widget_type || !isWebType(component.widget_type)) {
return false;
}
@@ -0,0 +1,560 @@
# INVYONE 컴포넌트 통합 플랜 — v2-* + legacy 86개 → 9종
> **작성일**: 2026-04-11
> **상태**: DRAFT — 사용자 확정 대기
> **작성**: gbpark + Claude
> **관련 문서**:
> - `2026-04-09-invyone-architecture.md` (ComponentType 8종 원안)
> - `2026-04-08-invyone-component-spec.md` (FieldConfig, DataPort, Config 규격)
> - `2026-04-10-card-engine-final-spec.md` (카드 엔진 진실의 원천)
---
## 0. 한 줄 요약
**`frontend/lib/registry/components/` 의 86개 폴더(v2-* 33 + legacy 53)를
9종의 통합 컴포넌트로 물리적으로 합치고 나머지는 삭제한다. 각 그룹은 하나의
Component 파일 + 하나의 ConfigPanel 을 가지며, 변형은 config 필드로만 표현.**
---
## 1. 배경 / 왜 통합이 필요한가
### 1.1 현재 문제
- `v2-input`, `v2-select`, `v2-date`, `v2-category-manager`**실질적으로 "필드 입력"** 역할을 하는 컴포넌트가 10개 이상 분산
- `v2-table-list`, `v2-table-grouped`, `v2-pivot-grid`, `v2-split-panel-layout`, `table-list`(legacy) 등 **"테이블"** 역할이 9개로 분산
- 각자 별도 ConfigPanel / 별도 규격 / 별도 props → **FieldConfig 공유 불가**
- 개발자가 화면 만들 때 "텍스트 입력 → `v2-input`? `text-input`? `v2-text-display`?" 같은 **이름 기반 선택 고문**
### 1.2 통합 후 기대
- 개발자: 팔레트에 "입력" 하나만 보임 → 드롭 후 `FieldConfig.type` 으로 모드 전환 (text / select / date / entity ...)
- 각 컴포넌트 규격이 통일되어 화면 수준 `fields: FieldConfig[]` 가 자동으로 흘러감
- V2PropertiesPanel 도 9종 ConfigPanel 만 다루면 됨 → 80개 분기 사라짐
### 1.3 FieldConfig 가 규격의 축
- 모든 "입력/표시" 계열 컴포넌트는 `FieldConfig` 를 받아서 type 에 따라 자동 렌더
- 이 전제가 통합의 핵심
---
## 2. 통합 방식 — A (물리적 통합) 확정
| 선택 | 설명 |
|---|---|
| **A. 물리적 통합 ✅** | 여러 v2-* / legacy 폴더를 하나의 새 폴더로 합침. 기존 폴더는 완전 삭제. 팔레트에서 영구 제거 |
| ~~B. 논리적 통합~~ | 파일은 유지하되 팔레트에서만 숨김. 버림 |
### 2.1 물리적 통합 원칙
1. **새 통합 폴더 생성** — 예: `components/table/`, `components/input/`, `components/button/`
2. **기존 폴더 내용을 새 폴더로 이관** — 가장 많이 쓰이는 것(예: v2-table-list)을 base 로
3. **기존 폴더 삭제** — (우선 `_obsolete/` 로 이동 → 확인 후 삭제)
4. **id 재지정**`v2-table-list` 등 접두사 없이 `table`, `input`, `button` 같은 순수 이름
5. **import 경로 치환** — 사용처에서 새 경로로
### 2.2 호환 계층
- 기존 화면의 컴포넌트 type (`v2-table-list` 등) 이 JSON 에 저장돼 있음
- **load 시점에 alias 매핑**: `v2-table-list``table`, `v2-input``input`, ...
- `ComponentRegistry` 에 legacy alias 테이블 추가
---
## 3. 그룹핑 결정 (9종 + 도메인 특화)
### 3.1 `table` — 데이터 테이블 (1종)
**목적**: 컬럼 기반 데이터 목록. 변형은 displayMode 로.
**통합 대상** (9개 → 1개):
| 기존 ID | 현재 폴더 | 흡수 후 config |
|---|---|---|
| `v2-table-list` | `v2-table-list/` | `displayMode: 'table'` (기본) |
| `v2-table-grouped` | `v2-table-grouped/` | `displayMode: 'grouped'` |
| `v2-pivot-grid` | `v2-pivot-grid/` | `displayMode: 'pivot'` |
| `v2-split-panel-layout` | `v2-split-panel-layout/` | `displayMode: 'split'` (master-detail) |
| `table-list` (legacy) | `table-list/` | 삭제 (v2 이전 버전) |
| `split-panel-layout` | `split-panel-layout/` | 삭제 |
| `split-panel-layout2` | `split-panel-layout2/` | 삭제 |
| `modal-repeater-table` | `modal-repeater-table/` | 삭제 (특수 케이스, 필요 시 config 흡수) |
| `simple-repeater-table` | `simple-repeater-table/` | 삭제 |
| `tax-invoice-list` | `tax-invoice-list/` | 삭제 (도메인 특화, 템플릿으로 처리) |
| `pivot-grid` (legacy) | `pivot-grid/` | 삭제 |
**새 폴더**: `components/table/`
**새 id**: `table`
**config**: `TableConfig` (invyone-component.ts §4.1)
---
### 3.2 `form` — 입력 폼 (1종)
**통합 대상** (2개 → 1개):
| 기존 ID | 폴더 | 액션 |
|---|---|---|
| `form-layout` (legacy, id=`field-example-1`) | `form-layout/` | base, 새 폴더로 이주 |
| `universal-form-modal` | `universal-form-modal/` | 흡수 (modal 옵션으로) |
**새 폴더**: `components/form/`
**새 id**: `form`
**config**: `FormConfig` (§4.2) — `columns`, `sections`, `saveAction`, `mode: 'inline' | 'modal'`
---
### 3.3 `search` — 검색 필터 (1종)
**통합 대상** (3개 → 1개):
| 기존 ID | 폴더 | 액션 |
|---|---|---|
| `v2-table-search-widget` | `v2-table-search-widget/` | **base** (가장 많이 씀) |
| `table-search-widget` (legacy) | `table-search-widget/` | 삭제 |
| `autocomplete-search-input` (legacy) | `autocomplete-search-input/` | 삭제 (input 그룹에서 autocomplete 옵션으로) |
**새 폴더**: `components/search/`
**새 id**: `search`
**config**: `SearchConfig` (§4.3)
---
### 3.4 `input` — 범용 필드 입력 (1종, 가장 큼)
**핵심**: 하나의 `input` 컴포넌트가 **`FieldConfig.type` 에 따라 완전히 다른 위젯으로 렌더**.
**통합 대상** (20개 이상 → 1개):
| 기존 ID | 역할 | 통합 후 FieldConfig.type |
|---|---|---|
| `v2-input` | text/number/password | `'text'` / `'number'` |
| `v2-select` | dropdown | `'select'` |
| `v2-date` | date picker | `'date'` / `'datetime'` |
| `v2-category-manager` | category select | `'select'` (with cascade) |
| `v2-file-upload` | file attach | `'file'` |
| `v2-media` | image/media | `'file'` (with preview) |
| `v2-numbering-rule` | auto-number | `'code'` |
| `entity-search-input` (legacy) | FK popup | `'entity'` |
| `v2-location-swap-selector` | location picker | `'entity'` (with swap UX) |
| `text-input` (legacy) | | 삭제 |
| `number-input` (legacy) | | 삭제 |
| `date-input` (legacy) | | 삭제 |
| `select-basic` (legacy) | | 삭제 |
| `checkbox-basic` (legacy) | | `'checkbox'` |
| `radio-basic` (legacy) | | `'select'` (렌더 옵션) |
| `toggle-switch` (legacy) | | `'checkbox'` (렌더 옵션) |
| `slider-basic` (legacy) | | `'number'` (렌더 옵션) |
| `textarea-basic` (legacy) | | `'textarea'` |
| `file-upload` (legacy) | | 삭제 |
| `image-display` / `image-widget` (legacy) | | 삭제 |
| `selected-items-detail-input` (legacy) | | 삭제 |
| `mail-recipient-selector` (legacy) | 흡수 검토 | `'entity'` (복수) 또는 도메인 특화 |
| `test-input` (legacy) | | 삭제 (테스트 코드) |
**새 폴더**: `components/input/`
**새 id**: `input`
**규격**: `FieldConfig` 를 직접 소비. `type` 별로 내부 분기 렌더.
**렌더 계약**: `2026-04-08-invyone-component-spec.md` §2.2 매핑 테이블
---
### 3.5 `button` — 액션 버튼 (1종)
**통합 대상** (3개 → 1개):
| 기존 ID | 폴더 | 액션 |
|---|---|---|
| `v2-button-primary` | `v2-button-primary/` | **base** |
| `button-primary` (legacy) | `button-primary/` | 삭제 (legacy) |
| `related-data-buttons` (legacy) | `related-data-buttons/` | 삭제 (버튼 그룹은 button-bar 또는 여러 button 배치) |
**새 폴더**: `components/button/`
**새 id**: `button`
**config**: `ButtonConfig` (§4.4) — `actionType: ActionType` (12종)
---
### 3.6 `stats` — 통계 / KPI 카드 (1종)
**통합 대상** (6개 → 1개):
| 기존 ID | 폴더 | 액션 |
|---|---|---|
| `v2-aggregation-widget` | `v2-aggregation-widget/` | **base** |
| `v2-status-count` | `v2-status-count/` | 흡수 (count 변형) |
| `v2-card-display` | `v2-card-display/` | 흡수 (card 변형) |
| `aggregation-widget` (legacy) | `aggregation-widget/` | 삭제 |
| `card-display` (legacy) | `card-display/` | 삭제 |
| `customer-item-mapping` (legacy) | `customer-item-mapping/` | 삭제 (도메인 특화) |
**새 폴더**: `components/stats/`
**새 id**: `stats`
**config**: `StatsConfig` (§4.6) — `items[]` (label, column, aggregation)
---
### 3.7 `title` — 제목 / 텍스트 (1종)
**통합 대상** (2개 → 1개):
| 기존 ID | 액션 |
|---|---|
| `v2-text-display` | **base** |
| `text-display` (legacy) | 삭제 |
**새 폴더**: `components/title/`
**새 id**: `title`
**config**: `TitleConfig` (§4.6) — `text`, `fontSize`, `fontWeight`, `align`
---
### 3.8 `divider` — 구분선 (1종)
**통합 대상** (3개 → 1개):
| 기존 ID | 액션 |
|---|---|
| `v2-divider-line` | **base** |
| `v2-split-line` | 흡수 (수직/수평 변형) |
| `divider-line` (legacy) | 삭제 |
**새 폴더**: `components/divider/`
**새 id**: `divider`
**config**: `DividerConfig``style`, `orientation`
---
### 3.9 `container` — 탭 / 섹션 / 아코디언 / 반복 (1종)
**통합 대상** (11개 → 1개): mockup 의 `LAYOUT` 카테고리 전체
| 기존 ID | 역할 | 통합 후 config |
|---|---|---|
| `v2-tabs-widget` | 탭 | `containerType: 'tabs'` |
| `v2-section-card` | 섹션 카드 | `containerType: 'section'` (card) |
| `v2-section-paper` | 섹션 페이퍼 | `containerType: 'section'` (paper) |
| `v2-repeat-container` | 반복 | `containerType: 'repeater'` |
| `v2-repeater` | 반복 | `containerType: 'repeater'` |
| `accordion-basic` (legacy) | 아코디언 | `containerType: 'accordion'` |
| `section-card` / `section-paper` (legacy) | | 삭제 |
| `tabs` (legacy) | | 삭제 |
| `conditional-container` (legacy) | 조건부 | `containerType: 'conditional'` |
| `repeat-container` / `repeat-screen-modal` / `repeater-field-group` | | 삭제 |
| `screen-split-panel` (legacy) | | 삭제 (table.split 에서 처리) |
**새 폴더**: `components/container/`
**새 id**: `container`
**config**: `ContainerConfig` (신규) — `containerType`, `children: Component[]`
---
## 4. 도메인 특화 — 통합 X, 그대로 유지
다음 컴포넌트는 **업무 특화 로직**이 너무 강해서 통합 불가. 그대로 유지하되 규격 정리(FieldConfig/DataPort 호환)만:
| 컴포넌트 | 도메인 |
|---|---|
| `v2-bom-tree` | BOM |
| `v2-bom-item-editor` | BOM |
| `v2-shipping-plan-editor` | 출하 |
| `v2-timeline-scheduler` | 일정 |
| `v2-process-work-standard` | 공정 표준 |
| `v2-approval-step` | 결재 |
| `v2-rack-structure` | 창고 |
| `v2-item-routing` | 라우팅 |
| `flow-widget` (legacy) | 플로우 |
| `map` (legacy) | 지도 |
이들은 `frontend/lib/registry/components/domain/` 하위로 이주.
---
## 5. 삭제 대상 — 통합도 유지도 X
| 폴더 | 이유 |
|---|---|
| `test-input` | 테스트 코드 |
| `common` | 유틸 (코드만 유지, 컴포넌트 아님 — 삭제 X) |
| `image-display`, `image-widget` | `input` 의 file 타입으로 흡수 |
| `tax-invoice-list` | 도메인 특화 + legacy |
---
## 6. 최종 컴포넌트 구조 (이후)
```
frontend/lib/registry/components/
├── table/ # 1종 — 데이터 테이블 (9개 흡수)
├── form/ # 1종 — 입력 폼 (2개 흡수)
├── search/ # 1종 — 검색 필터 (3개 흡수)
├── input/ # 1종 — 범용 입력 (20+개 흡수, 가장 큼)
├── button/ # 1종 — 액션 버튼 (3개 흡수)
├── stats/ # 1종 — 통계/KPI (6개 흡수)
├── title/ # 1종 — 제목/텍스트 (2개 흡수)
├── divider/ # 1종 — 구분선 (3개 흡수)
├── container/ # 1종 — 탭/섹션/아코디언/반복 (11개 흡수)
└── domain/ # 도메인 특화 (그대로 유지, 약 10개)
├── bom-tree/
├── bom-item-editor/
├── shipping-plan-editor/
├── timeline-scheduler/
├── process-work-standard/
├── approval-step/
├── rack-structure/
├── item-routing/
├── flow-widget/
└── map/
```
**결과**: 86개 → **9 통합 + 10 도메인 = 19개 폴더** (78% 감소)
---
## 7. 통합 작업 순서
### Phase A — 기반 (1단계씩, 작고 안전한 것부터)
1. **`divider`** (3→1) — 가장 간단, 5분 작업
2. **`title`** (2→1) — 간단
3. **`button`** (3→1) — 중간
4. **`search`** (3→1) — 중간
### Phase B — 대규모 (본편)
5. **`input`** (20+→1) — 가장 크고 중요. FieldConfig.type 기반 분기
6. **`stats`** (6→1)
7. **`form`** (2→1)
### Phase C — 가장 복잡
8. **`table`** (9→1) — 가장 많은 기능, displayMode 변형 다수
9. **`container`** (11→1) — 탭/섹션/아코디언/반복
### Phase D — 도메인 정리
10. `domain/` 폴더로 이주 (파일 이동만, 로직 변경 X)
### Phase E — 청소
11. Legacy 폴더 전체 삭제
12. `ComponentRegistry` legacy alias 추가
13. 저장된 화면의 component type 자동 마이그레이션 (`v2-table-list``table` 등)
---
## 8. 파일별 액션 매트릭스
| 상태 | 의미 | 개수 |
|---|---|---|
| **base** | 새 통합 폴더의 기준이 되는 원본 (예: v2-table-list → table) | 9개 |
| **흡수** | base 에 기능 병합 후 원본 폴더 삭제 | 약 30개 |
| **삭제** | 완전히 제거 (legacy 또는 중복) | 약 35개 |
| **도메인 유지** | `domain/` 으로 이주만 | 약 10개 |
| **유틸 유지** | `common/` 등 코드 그대로 | 약 2개 |
---
## 9. 리스크 & 완화
### 9.1 기존 저장 화면이 깨질 위험
- DB 에 저장된 화면 JSON 이 `v2-table-list` 같은 구 id 를 참조
- **완화**: `ComponentRegistry` 에 **alias 맵**을 등록해서 구 id 요청 시 새 컴포넌트로 자동 리다이렉트
```ts
const legacyAliases = {
'v2-table-list': 'table',
'v2-input': 'input',
'v2-select': 'input',
'v2-date': 'input',
// ...
};
```
### 9.2 기능 누락
- 각 흡수 과정에서 특이 케이스(예: v2-pivot-grid 의 pivot 로직)를 빼먹으면 기능 상실
- **완화**: Phase 단위로 진행 + 각 단계마다 기능 체크리스트 확인
### 9.3 세션 초과
- 한 세션에 전부 못 함. 여러 세션 필요.
- **완화**: Phase 순서대로 진행. 각 Phase 완료 시 커밋.
### 9.4 롤백
- 각 Phase 시작 전 git commit. 문제 생기면 되돌림.
- Legacy 폴더는 바로 삭제 X → `_obsolete/legacy/<name>/` 로 이동 후 최종 청소 단계에서 삭제
---
## 10. 검증 체크리스트 (Phase 별)
### 각 Phase 완료 시
- [ ] 새 통합 폴더가 생성됨
- [ ] 새 컴포넌트가 `ComponentRegistry` 에 등록됨
- [ ] 기존 폴더가 `_obsolete/` 로 이동됨
- [ ] 팔레트에서 새 컴포넌트가 보임
- [ ] 기존에 저장된 화면이 **alias 를 통해** 정상 렌더됨
- [ ] `npx tsc --noEmit` 통과 (해당 영역 에러 0개)
- [ ] V2PropertiesPanel 에서 새 컴포넌트 config 편집 가능
### 전체 완료 시
- [ ] `components/` 폴더에 9 통합 + `domain/` + `common/` 만 남음
- [ ] 모든 컴포넌트가 `FieldConfig` / `DataPort` / `ComponentTypeConfig` 규격 준수
- [ ] 기존 VEX 화면 80개 이상 샘플이 정상 동작
- [ ] Legacy 폴더 완전 삭제
---
## 10.5. ★ Phase 마다 반드시 할 일 (체크리스트)
Phase A-1/A-2 에서 발견된 이슈들. 각 Phase 에서 통합할 때 **반드시**:
1. **새 통합 폴더 생성 + 파일 작성** (types, Component, ConfigPanel, Renderer, index)
2. **`frontend/lib/registry/components/index.ts` 에 새 Renderer import 추가**
3. **기존 흡수 대상 `index.ts` 에 `hidden: true` 추가** (미래 대비)
4. **`ComponentsPanel.tsx:107``hiddenComponents` 배열에 흡수 대상 id 전부 추가** ⚠️
- 이유: `ComponentsPanel``ComponentDefinition.hidden` 필드를 **무시**하고
이 배열로 필터함
5. **`lib/utils/getComponentConfigPanel.tsx:9``CONFIG_PANEL_MAP` 에 새 컴포넌트
등록** ⚠️
- 이유: V2PropertiesPanel 이 `ComponentDefinition.config_panel` 을 **무시**하고
이 하드코딩 맵으로 ConfigPanel 을 찾음
- 패턴: `"<new-id>": () => import("@/lib/registry/components/<new-folder>/<Name>ConfigPanel")`
6. **타입 체크**: `npx tsc --noEmit 2>&1 | grep <new-folder>`
7. **브라우저 확인**: 검색 → 1개만, 드롭 → 성공, 선택 → **우측 속성 패널에 ConfigPanel 뜸**
### ★ 드래그 드롭 기존 버그 (2026-04-11 발견, 수정 완료)
`ScreenDesigner.handleComponentDrop``component.defaultSize` (camelCase) 로
접근하지만 `ComponentDefinition``default_size` (snake_case) 로 저장. 격자
스냅이 켜진 상태에서 TypeError 로 drop 이 조용히 중단됨.
**픽스**: `ComponentsPanel.handleDragStart` 가 drag data 에 camelCase 별칭
(`defaultSize`, `defaultConfig`, `webType`) 을 주입해서 호환.
### ★ 컴포넌트 설정 저장·전달 경로 (Phase A-2 발견, 4단계 머지 필요)
layout 의 각 컴포넌트는 설정을 **`componentConfig`** 필드에 저장한다
(`component.config` 아님). 그리고 렌더 시점에 `DynamicComponentRenderer.tsx:882`
가 이걸 **top-level props 로 spread** 해서 전달하므로, 실제로는 4가지 경로가
동시에 존재한다:
1. `props.config``DynamicComponentRenderer` 가 명시적으로 주입
2. `props.componentConfig` — 같은 값을 다른 이름으로 주입
3. `props.component.componentConfig` — component 객체 안의 실제 저장 경로
4. **`props.*` (top-level spread)** — `mergedComponentConfig``{...}` spread
로 펼쳐서 각 필드가 직접 props 에 들어감 (text, color, thickness 등)
또한 일부 경로에서 `component.config` 를 legacy 로 쓰는 경우가 있음.
**새 통합 컴포넌트의 렌더 함수 (XxxComponent.tsx) 는 반드시**:
```tsx
// 1) 먼저 top-level spread 된 config 필드들을 props 에서 추출
const fromProps: Partial<XxxConfig> = {};
const p = props as any;
if (p.text !== undefined) fromProps.text = p.text;
if (p.color !== undefined) fromProps.color = p.color;
// ... 각 config 필드 추출
// 2) 4경로 머지 (마지막이 우선순위 최상위)
const componentConfig = {
...config,
...((component as any).config ?? {}), // legacy
...((component as any).componentConfig ?? {}), // 저장 경로
...fromProps, // ★ top-level spread (최우선)
} as XxxConfig;
```
`fromProps` 가 빠지면 ConfigPanel 변경이 간헐적으로 또는 전혀 반영 안 됨.
(Phase A-2 에서 title / divider 가 이 증상 보임)
### ★ DynamicComponentRenderer 의 componentConfig 읽기 버그 (Phase A-2 결정타)
`DynamicComponentRenderer.tsx:848`**`component.component_config`** (snake_case)
만 읽고 있었는데, `V2PropertiesPanel.onUpdateProperty(id, "componentConfig", ...)`
**`componentConfig`** (camelCase) 에 저장. 이 불일치 때문에 **설정 변경이 리렌더
때 사라지고 있었음**.
기존 v2-* 컴포넌트는 `mergeColumnMeta` 가 DB 컬럼 메타를 자동 채워줘서 이 버그가
숨겨져 있었음. **컬럼 없는 일반 컴포넌트(divider/title)** 만 정면으로 영향.
**픽스** (2026-04-11):
```ts
const savedConfig =
(component as any).componentConfig || // camelCase (저장 경로)
component.component_config || // snake_case (legacy fallback)
{};
```
### ★ DynamicComponentRenderer 자동 v2- 매핑 함정 (Phase B-1 결정타)
`DynamicComponentRenderer.tsx:351``mapToV2ComponentType` 은 componentType
**자동으로 `v2-` 접두사를 붙여서 resolve 시도**:
```ts
const v2Type = `v2-${type}`;
if (ComponentRegistry.hasComponent(v2Type)) {
return v2Type; // ← 자동 리다이렉트
}
```
즉 내가 `input` 이라는 새 통합 컴포넌트를 만들어도, 팔레트에서 드롭된 블록의
`componentType='input'`**`v2-input` 으로 리다이렉트**되어 기존 v2-input
컴포넌트가 렌더된다. 내 InputComponent 는 호출조차 안 됨.
Phase A-1~A-4 는 우연히 피했음:
- `divider``v2-divider` 없음 (v2-divider-line 은 다른 이름)
- `title``v2-title` 없음 (v2-text-display 는 다른 이름)
- `button``v2-button` 없음 (v2-button-primary)
- `search``v2-search` 없음 (v2-table-search-widget)
**B-1 input 만 정면으로 걸림**. `v2-input` 이 존재하므로 자동 매핑됨.
**픽스** (2026-04-11):
```ts
const INVYONE_UNIFIED_IDS = new Set([
"divider", "title", "button", "search",
"input", "stats", "form", "table", "container",
]);
const mapToV2ComponentType = (type) => {
if (INVYONE_UNIFIED_IDS.has(type)) return type; // ★ 매핑 제외
// ...
};
```
**Phase 마다 체크리스트에 추가**: 새 통합 컴포넌트 id 를 이 Set 에 추가.
### 새 통합 컴포넌트 표준 패턴 (Phase A-3 부터)
모든 새 통합 컴포넌트의 `XxxComponent.tsx` 는 다음 패턴을 **처음부터** 따를 것:
1. **fromProps 추출**: top-level spread 된 config 필드를 `props` 에서 추출
2. **4경로 머지**: `{ ...config, ...component.config, ...component.componentConfig, ...fromProps }`
3. **DOM props filter**: config 필드들을 `...domProps` 에 남기지 말고 destructure 로
제거 (React warning 방지)
이 3가지 빠지면 설정이 반영 안 되거나 콘솔 에러 폭주.
### 중장기 TODO (청소 단계 Phase E 에서)
- `ComponentsPanel` 필터를 `!c.hidden` 으로 리팩토링해서 `hiddenComponents` 배열 제거
- `getComponentConfigPanel``ComponentDefinition.config_panel` 직접 사용으로 리팩토링
- `handleComponentDrop``default_size` (snake_case) 로 통일
- 그러면 위 체크리스트 4번, 5번 단계가 자동화됨
---
## 11. 다음 세션 작업 목록
1. **Phase A-1**: `divider` 통합 (3→1) — 워밍업
2. **Phase A-2**: `title` 통합 (2→1)
3. **Phase A-3**: `button` 통합 (3→1)
4. **Phase A-4**: `search` 통합 (3→1)
5. Phase A 완료 → 커밋 → Phase B 시작
각 Phase 는 별도 커밋. 각 커밋 메시지 예:
```
refactor(components): unify divider group (3→1)
- v2-divider-line, v2-split-line, divider-line → components/divider/
- legacy aliases registered
```
---
## 끝
이 문서가 86→9 통합의 **진실의 원천**. 이후 세션에서 작업 시작 시 이 문서 참조.
변경이 생기면 이 문서를 먼저 수정.
@@ -0,0 +1,331 @@
# INVYONE 화면 디자이너 기반 템플릿 구조 — FieldConfig + DataPort 통합
**작성일**: 2026-04-11
**진입점**: `/admin/builder`
**기반**: `frontend/components/screen/ScreenDesigner.tsx` (VEX 화면 디자이너)
---
## 0. 방향성
- INVYONE 의 **템플릿 = 화면 디자이너로 제작한 화면**
- 제작 도구: VEX 화면 디자이너 (`ScreenDesigner`)
- 제작된 화면은 대시보드에서 카드로 배치된다
- 각 컴포넌트는 **FieldConfig** 로 필드를 정의하고, **DataPort** 로 통신한다
- 화면 디자이너의 자유배치 UX 는 그대로 유지
---
## 1. 현재 구조
### 1.1 ScreenDefinition
- 위치: `frontend/types/index.ts`
- 핵심 필드: `screen_id`, `screen_name`, `screen_code`, `table_name`, `layout_data`
- `layout_data.components: ComponentData[]` — 자유배치 컴포넌트 배열
### 1.2 ComponentData
- `type`, `position { x, y, w, h }`, `config`, `style`
- `config` 는 컴포넌트 타입마다 다른 구조:
- `v2-table-list`: `{ columns: ColumnConfig[], cardConfig, pagination, filter, actions, ... }`
- `v2-table-search-widget`: 검색 필드 정의
- `v2-button-primary`: 버튼 액션
- 등
### 1.3 ComponentRegistry
- 위치: `frontend/lib/registry/ComponentRegistry.ts`
- 각 `v2-*` 컴포넌트가 `ComponentDefinition` 으로 등록
- 필드: `id`, `default_config`, `default_size`, `component`, `config_panel`, `category`, `tags`
### 1.4 이벤트 통신
- 위치: `frontend/lib/v2-core/events/EventBus.ts`
- 현재는 각 컴포넌트가 암묵적으로 publish/subscribe
- DataPort 의 런타임 구현체로 재사용 예정
---
## 2. FieldConfig 통합
### 2.1 목표
화면의 모든 컴포넌트가 **유일한 필드 규격** 을 공유한다. 테이블 컬럼, 폼 입력, 검색 필터가 전부 같은 `FieldConfig` 배열에서 파생된다.
### 2.2 FieldConfig 스펙 (이미 정의됨)
```typescript
// frontend/types/invyone-component.ts
interface FieldConfig {
column: string; // DB 컬럼명 (유일 키)
label: string; // 표시 라벨
type: FieldType; // text/number/date/select/entity/...
visible: boolean;
order: number;
required: boolean;
editable: boolean;
searchable?: boolean;
sortable?: boolean;
width?: number;
align?: 'left' | 'center' | 'right';
defaultValue?: unknown;
options?: FieldOption[]; // select 타입
ref?: FieldRef; // entity 타입 (FK)
format?: string;
computed?: string;
pk?: boolean;
system?: boolean;
}
```
### 2.3 ScreenDefinition 확장
```typescript
interface ScreenDefinition {
// ... 기존 필드
fields?: FieldConfig[]; // 신규: 화면 수준 필드 규격
connections?: Connection[]; // 신규: DataPort 연결 (§3)
}
```
- 화면 수준에서 필드 목록을 **한 번만** 정의
- 각 컴포넌트는 이 `fields` 를 참조해 자기 용도로 파생
- `table_name` 이 있으면 빌더에서 메타 API 로 자동 초기화
### 2.4 어댑터 함수
`frontend/lib/fieldConfig/adapters.ts` (신규)
```typescript
export function fieldsToColumns(fields: FieldConfig[]): ColumnConfig[]
export function fieldsToSearchFields(fields: FieldConfig[]): SearchFieldConfig[]
export function fieldsToFormFields(fields: FieldConfig[]): FormFieldConfig[]
```
- FieldConfig → 각 컴포넌트 내부 포맷으로 변환
- 역방향 필요 시 `columnsToFields` 등도 추가
### 2.5 컴포넌트 props 경로
`v2-*` 컴포넌트는 두 경로를 지원:
| 경로 | 설명 | 우선순위 |
|---|---|---|
| **(a) 구 경로** | `config.columns: ColumnConfig[]` 등 컴포넌트별 구조 | 호환용 |
| **(b) 신 경로** | 화면 수준 `fields: FieldConfig[]` 를 props 로 받아 내부 변환 | 신규 권장 |
fields 가 있으면 (b) 우선, 없으면 (a) fallback.
### 2.6 빌더 UX — "필드 관리" 패널
`ScreenDesigner` 의 기존 패널 시스템에 "필드" 탭 추가:
- 화면 수준 `fields` 배열 편집
- `table_name` 설정 시 메타 API `/api/meta/fields/{table}` 로 초기 FieldConfig[] 자동 생성
- 각 필드 편집:
- label, type, visible, searchable, required, editable, order
- type=select 시 options 편집
- type=entity 시 ref 편집 (팝업 검색 대상 테이블/컬럼)
- 저장 시 `ScreenDefinition.fields` 로 직렬화
각 컴포넌트의 속성 패널에는:
- "이 컴포넌트에서 사용할 필드 선택" (체크박스, 화면 수준 fields 의 subset)
- 또는 "필드별 오버라이드" (라벨/폭 등 컴포넌트 단위 조정)
---
## 3. DataPort 통합
### 3.1 목표
컴포넌트 간 통신을 **명시적 Port 연결** 로 표현한다. 현재 `v2EventBus` 는 암묵적이다. DataPort 는 "어느 컴포넌트가 무엇을 받고 내보내는지" 를 **메타로 선언** 하고, 빌더에서 시각적으로 연결한다.
### 3.2 DataPort 스펙 (이미 정의됨)
```typescript
// frontend/types/invyone-component.ts
type DataPortType = 'row' | 'rows' | 'value' | 'params';
interface DataPort {
name: string; // 'selectedRow', 'searchParams' 등
type: DataPortType;
connectedTo?: string; // 대상 컴포넌트 ID (Connection 으로 대체 가능)
}
interface Connection {
id: string;
from: { componentId: string; port: string };
to: { componentId: string; port: string };
}
```
### 3.3 ComponentDefinition 확장
`v2-*` 컴포넌트의 `createComponentDefinition({ ... })``dataPorts` 추가:
```typescript
createComponentDefinition({
id: 'v2-table-list',
// ... 기존 필드
dataPorts: {
inputs: [
{ name: 'searchParams', type: 'params' },
{ name: 'refreshTrigger', type: 'value' },
],
outputs: [
{ name: 'selectedRow', type: 'row' },
{ name: 'selectedRows', type: 'rows' },
],
},
});
```
우선순위 1 컴포넌트 정의 (초안):
| 컴포넌트 | inputs | outputs |
|---|---|---|
| `v2-table-list` | `searchParams`, `refreshTrigger` | `selectedRow`, `selectedRows` |
| `v2-table-search-widget` | — | `searchParams` |
| `v2-button-primary` | `disabled`, `formData` | `clicked` |
| `v2-input` / `v2-select` / `v2-date` | `value` | `value`, `changed` |
| `v2-aggregation-widget` | `data` | — |
| `v2-text-display` | `text` | — |
| `v2-card-display` | `items` | `selected` |
### 3.4 ScreenDefinition 확장
```typescript
interface ScreenDefinition {
// ... 기존 + fields
connections?: Connection[];
}
```
- 빌더에서 사용자가 시각적으로 연결 → `connections` 배열로 저장
- 화면 로드 시 런타임이 이 배열을 읽어 EventBus 브리지 설정
### 3.5 런타임 구현
`frontend/lib/dataPort/runtime.ts` (신규)
```typescript
/**
* 화면 로드 시 Connection 배열을 EventBus 브리지로 변환.
* from 컴포넌트가 output 포트에 publish → to 컴포넌트가 input 포트로 subscribe.
*/
export function setupConnections(
connections: Connection[],
bus: EventBus,
): () => void // cleanup 함수 반환
```
- 기존 `v2-core/events/EventBus` 를 그대로 사용
- DataPort 는 그 위의 **선언적 레이어**
- 컴포넌트는 포트 단위로 publish/subscribe — 직접 이벤트 이름 다룰 필요 없음
### 3.6 빌더 UX — Connection 시각화
`ScreenDesigner` 캔버스 위에 포트 UI 추가:
- 선택된 컴포넌트의 `inputs` / `outputs` 가 컴포넌트 **가장자리에 원형 포트** 로 표시
- inputs: 왼쪽
- outputs: 오른쪽
- 포트를 드래그 → 다른 컴포넌트의 포트로 드롭 → Connection 생성
- 타입 체크: `output.type === input.type` 이어야 연결 허용 (`row``row`, `params``params`)
- 연결선은 SVG 로 렌더 (베지어 곡선)
- 속성 패널에 "연결" 탭 추가 — 현재 연결 목록 + 수동 추가/삭제
---
## 4. 구현 순서
### Step 1: 타입 확장
- [ ] `ScreenDefinition.fields?: FieldConfig[]` 추가
- [ ] `ScreenDefinition.connections?: Connection[]` 추가
- [ ] `ComponentDefinition.dataPorts?: { inputs, outputs }` 추가
- 위치: `frontend/types/index.ts``frontend/types/component.ts`
### Step 2: 어댑터 함수
- [ ] `frontend/lib/fieldConfig/adapters.ts` 신규
- [ ] `fieldsToColumns(fields)` — v2-table-list 의 `ColumnConfig` 로 변환
- [ ] `fieldsToSearchFields(fields)` — v2-table-search-widget 포맷으로
- [ ] `fieldsToFormFields(fields)` — 폼 컴포넌트 포맷으로
- [ ] 단위 테스트 (선택)
### Step 3: v2-table-list 통합
- [ ] TableListComponent props 에 `fields?: FieldConfig[]` 추가
- [ ] 내부에서 fields 가 있으면 `fieldsToColumns``config.columns` override
- [ ] 기존 `config.columns` 전달 경로 유지 (호환)
- [ ] ComponentDefinition 에 `dataPorts` 선언
### Step 4: ScreenDesigner "필드" 패널
- [ ] 기존 패널 시스템에 `"fields"` 탭 추가
- [ ] `table_name` 선택 시 `/api/meta/fields/{table}` 호출 → FieldConfig[] 자동 생성
- [ ] 필드 편집 UI (label/type/visible/searchable/required/order)
- [ ] 저장 시 `ScreenDefinition.fields` 에 직렬화
- [ ] 기존 v2-table-list 의 컬럼 설정과 연동 (fields 변경 → table-list 자동 반영)
### Step 5: DataPort 런타임
- [ ] `frontend/lib/dataPort/runtime.ts` 신규
- [ ] `setupConnections(connections, bus)` 구현
- [ ] 화면 로드 시점 (ScreenDefinition 렌더러) 에 호출
- [ ] cleanup 함수로 unmount 시 브리지 해제
### Step 6: DataPort 빌더 UX
- [ ] 컴포넌트 가장자리 포트 원 렌더
- [ ] 포트 드래그 & 드롭으로 Connection 생성
- [ ] SVG 연결선 렌더 (베지어)
- [ ] 타입 호환성 체크
- [ ] 속성 패널 "연결" 탭
### Step 7: 나머지 v2-* 컴포넌트 통합
- [ ] 우선순위 1 컴포넌트 전부에 `dataPorts` 선언
- [ ] 각 컴포넌트 props 에 `fields` 경로 추가
- [ ] 점진 확대 (우선순위 2/3 는 이후)
---
## 5. 호환성 원칙
1. **기존 데이터 그대로 동작**`ScreenDefinition.fields` / `connections` 는 옵셔널. 기존 화면은 수정 없이 그대로 작동.
2. **컴포넌트 props 이중 경로** — 구 경로 (`config.columns`) 와 신 경로 (`fields`) 공존. fields 가 있으면 우선.
3. **EventBus 내부 유지** — DataPort 는 그 위의 선언 레이어. EventBus 자체는 재작성 안 함.
4. **점진 마이그레이션** — 한 번에 전부 바꾸지 않고 컴포넌트 단위로 통합.
---
## 6. 확인 필요 사항 (다음 세션에서 코드 확인)
- `frontend/types/index.ts``ScreenDefinition` 정확한 정의 — 어떤 필드들이 이미 있는지
- `frontend/lib/v2-core/events/EventBus.ts` 의 publish/subscribe API 시그니처
- `v2-table-list` 가 내부에서 `ColumnConfig[]` 를 어떻게 consuming 하는지 (props → state → render)
- `/api/meta/fields/{table}` 응답 포맷 — FieldConfig 와 매핑 가능한지
- 기존 ScreenDefinition 에 `fields` 비슷한 필드가 이미 있는지 (중복 정의 방지)
- `ComponentDefinition` 타입 경로 — `dataPorts` 필드 추가할 위치
---
## 7. 연결 관계도
```
ScreenDefinition
├── fields: FieldConfig[] ← 화면 수준 필드 규격
├── layout_data.components[] ← 자유배치 컴포넌트 (기존)
│ └── 각 컴포넌트가 fields 참조 (adapter 로 변환)
└── connections: Connection[] ← DataPort 연결 목록
└── 런타임에 EventBus 브리지로 변환
ComponentRegistry
└── 각 v2-* ComponentDefinition
├── dataPorts.inputs[] ← 이 컴포넌트가 받는 포트
└── dataPorts.outputs[] ← 이 컴포넌트가 내보내는 포트
v2-core EventBus (내부 구현체)
└── DataPort 런타임이 사용
```
---
## 끝
화면 디자이너 = 템플릿 제작 도구
FieldConfig = 필드 규격 통합
DataPort = 컴포넌트 통신 명시
이 3가지로 VEX 화면 디자이너 위에 INVYONE 계층을 얹는다.