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:
@@ -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")}
|
||||
|
||||
@@ -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[] => {
|
||||
|
||||
@@ -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 />
|
||||
“메타에서 로드” 를 눌러 자동 생성하세요.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
화면에 테이블을 지정한 뒤
|
||||
<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>
|
||||
|
||||
@@ -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();
|
||||
@@ -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());
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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]">
|
||||
항목이 없습니다. “+추가” 를 눌러주세요.
|
||||
</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"),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 계층을 얹는다.
|
||||
Reference in New Issue
Block a user