1278 lines
52 KiB
TypeScript
1278 lines
52 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect, Suspense } from "react";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Separator } from "@/components/ui/separator";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
|
import { ChevronDown, Settings, Info, Database, Trash2, Copy, Palette } from "lucide-react";
|
|
import {
|
|
ComponentData,
|
|
WebType,
|
|
WidgetComponent,
|
|
GroupComponent,
|
|
DataTableComponent,
|
|
TableInfo,
|
|
FileComponent,
|
|
AreaComponent,
|
|
} from "@/types/screen";
|
|
import { ColumnSpanPreset, COLUMN_SPAN_PRESETS } from "@/lib/constants/columnSpans";
|
|
|
|
// 컬럼 스팬 숫자 배열 (1~12)
|
|
// 동적으로 컬럼 수 배열 생성 (gridSettings.columns 기반)
|
|
const generateColumnNumbers = (maxColumns: number) => {
|
|
return Array.from({ length: maxColumns }, (_, i) => i + 1);
|
|
};
|
|
import { cn } from "@/lib/utils";
|
|
import DataTableConfigPanel from "./DataTableConfigPanel";
|
|
import { WebTypeConfigPanel } from "./WebTypeConfigPanel";
|
|
import { FileComponentConfigPanel } from "./FileComponentConfigPanel";
|
|
import { useWebTypes } from "@/hooks/admin/useWebTypes";
|
|
import { isFileComponent, isTableLikeComponentType } from "@/lib/utils/componentTypeUtils";
|
|
import {
|
|
BaseInputType,
|
|
BASE_INPUT_TYPE_OPTIONS,
|
|
getBaseInputType,
|
|
getDefaultDetailType,
|
|
getDetailTypes,
|
|
DetailTypeOption,
|
|
} from "@/types/input-type-mapping";
|
|
|
|
import { ColorPickerWithTransparent } from "../common/ColorPickerWithTransparent";
|
|
|
|
// ComponentRegistry import (동적 ConfigPanel 가져오기용)
|
|
import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";
|
|
import { DynamicComponentConfigPanel, hasComponentConfigPanel } from "@/lib/utils/getComponentConfigPanel";
|
|
import StyleEditor from "../StyleEditor";
|
|
import { Slider } from "@/components/ui/slider";
|
|
import "@/components/v2/config-panels/_shared/cp/cp.css";
|
|
import {
|
|
CPSection,
|
|
CPRow,
|
|
CPText,
|
|
CPNumber,
|
|
CPSwitch,
|
|
CPGroup,
|
|
} from "@/components/v2/config-panels/_shared/cp";
|
|
import { Zap } from "lucide-react";
|
|
import { ConditionalConfigPanel } from "@/components/v2/ConditionalConfigPanel";
|
|
import { ConditionalConfig } from "@/types/v2-components";
|
|
|
|
type ConfigPanelTableOption = {
|
|
tableName: string;
|
|
displayName?: string;
|
|
tableComment?: string;
|
|
};
|
|
|
|
function normalizeConfigPanelTables(raw: any): ConfigPanelTableOption[] {
|
|
const items = Array.isArray(raw)
|
|
? raw
|
|
: Array.isArray(raw?.tables)
|
|
? raw.tables
|
|
: [];
|
|
|
|
return items
|
|
.map((table: any) => {
|
|
const tableName = table?.tableName || table?.table_name || table?.name;
|
|
if (!tableName) return null;
|
|
return {
|
|
tableName,
|
|
displayName: table?.displayName || table?.display_name || table?.tableLabel || table?.table_label,
|
|
tableComment: table?.tableComment || table?.table_comment || table?.description,
|
|
};
|
|
})
|
|
.filter((table: ConfigPanelTableOption | null): table is ConfigPanelTableOption => table !== null);
|
|
}
|
|
|
|
interface V2PropertiesPanelProps {
|
|
selectedComponent?: ComponentData;
|
|
tables: TableInfo[];
|
|
onUpdateProperty: (componentId: string, path: string, value: any) => void;
|
|
onDeleteComponent?: (componentId: string) => void;
|
|
onCopyComponent?: (componentId: string) => void;
|
|
currentTable?: TableInfo;
|
|
currentTableName?: string;
|
|
dragState?: any;
|
|
// 스타일 관련
|
|
onStyleChange?: (style: any) => void;
|
|
// 🆕 플로우 위젯 감지용
|
|
allComponents?: ComponentData[];
|
|
// 🆕 메뉴 OBJID (코드/카테고리 스코프용)
|
|
menuObjid?: number;
|
|
// 🆕 현재 편집 중인 화면의 회사 코드
|
|
currentScreenCompanyCode?: string;
|
|
}
|
|
|
|
export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
|
selectedComponent,
|
|
tables,
|
|
onUpdateProperty,
|
|
onDeleteComponent,
|
|
onCopyComponent,
|
|
currentTable,
|
|
currentTableName,
|
|
currentScreenCompanyCode,
|
|
dragState,
|
|
onStyleChange,
|
|
menuObjid,
|
|
allComponents = [], // 🆕 기본값 빈 배열
|
|
}) => {
|
|
const { webTypes } = useWebTypes({ active: "Y" });
|
|
const [localComponentDetailType, setLocalComponentDetailType] = useState<string>("");
|
|
|
|
// 높이/너비 입력 로컬 상태 (자유 입력 허용)
|
|
const [localHeight, setLocalHeight] = useState<string>("");
|
|
const [localWidth, setLocalWidth] = useState<string>("");
|
|
|
|
// 🆕 전체 테이블 목록 (selected-items-detail-input 등에서 사용)
|
|
const [allTables, setAllTables] = useState<ConfigPanelTableOption[]>([]);
|
|
|
|
// V2 입력/선택 폐기 (2026-05-12) — DB input_type 사전 fetch 가 이 prefetch 의 유일한
|
|
// 소비처였음. canonical input 의 InvFieldConfigPanel 은 패널 자체에서 메타를 가져옴.
|
|
|
|
// 🆕 전체 테이블 목록 로드
|
|
useEffect(() => {
|
|
const loadAllTables = async () => {
|
|
try {
|
|
const { tableManagementApi } = await import("@/lib/api/tableManagement");
|
|
const response = await tableManagementApi.getTableList();
|
|
if (response.success && response.data) {
|
|
setAllTables(normalizeConfigPanelTables(response.data));
|
|
}
|
|
} catch (error) {
|
|
console.error("전체 테이블 목록 로드 실패:", error);
|
|
}
|
|
};
|
|
loadAllTables();
|
|
}, []);
|
|
|
|
// 새로운 컴포넌트 시스템의 webType 동기화
|
|
useEffect(() => {
|
|
if (selectedComponent?.type === "component") {
|
|
const webType = selectedComponent.componentConfig?.webType;
|
|
if (webType) {
|
|
setLocalComponentDetailType(webType);
|
|
}
|
|
}
|
|
}, [selectedComponent?.type, selectedComponent?.componentConfig?.webType, selectedComponent?.id]);
|
|
|
|
// 높이 값 동기화
|
|
useEffect(() => {
|
|
if (selectedComponent?.size?.height !== undefined) {
|
|
setLocalHeight(String(selectedComponent.size.height));
|
|
}
|
|
}, [selectedComponent?.size?.height, selectedComponent?.id]);
|
|
|
|
// 너비 값 동기화
|
|
useEffect(() => {
|
|
if (selectedComponent?.size?.width !== undefined) {
|
|
setLocalWidth(String(selectedComponent.size.width));
|
|
}
|
|
}, [selectedComponent?.size?.width, selectedComponent?.id]);
|
|
|
|
// 컴포넌트가 선택되지 않았을 때는 안내 메시지만 표시
|
|
if (!selectedComponent) {
|
|
return (
|
|
<div className="flex h-full flex-col overflow-x-auto">
|
|
<div className="flex-1 overflow-x-auto overflow-y-auto p-2">
|
|
<div className="space-y-4 text-xs">
|
|
{/* 안내 메시지 */}
|
|
<div className="flex flex-col items-center justify-center py-8 text-center">
|
|
<Settings className="text-muted-foreground/30 mb-2 h-8 w-8" />
|
|
<p className="text-muted-foreground text-[10px]">컴포넌트를 선택하여</p>
|
|
<p className="text-muted-foreground text-[10px]">속성을 편집하세요</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const handleUpdate = (path: string, value: any) => {
|
|
onUpdateProperty(selectedComponent.id, path, value);
|
|
};
|
|
|
|
// 드래그 중일 때 실시간 위치 표시
|
|
const currentPosition =
|
|
dragState?.isDragging && dragState?.draggedComponent?.id === selectedComponent.id
|
|
? dragState.currentPosition
|
|
: selectedComponent.position;
|
|
|
|
// 컴포넌트별 설정 패널 렌더링 함수 (DetailSettingsPanel의 로직)
|
|
const renderComponentConfigPanel = () => {
|
|
if (!selectedComponent) return null;
|
|
|
|
// 🎯 Section Card, Section Paper 등 신규 컴포넌트는 componentType에서 감지
|
|
const componentType =
|
|
selectedComponent.componentType || // ⭐ 1순위: ScreenDesigner가 설정한 componentType (section-card 등)
|
|
selectedComponent.componentConfig?.type ||
|
|
selectedComponent.componentConfig?.id ||
|
|
selectedComponent.type;
|
|
|
|
// 🆕 ComponentRegistry에서 ConfigPanel 가져오기 시도
|
|
const componentId =
|
|
selectedComponent.componentType || // ⭐ section-card 등
|
|
selectedComponent.componentConfig?.type ||
|
|
selectedComponent.componentConfig?.id ||
|
|
(selectedComponent.type === "component" ? selectedComponent.id : null); // 🆕 독립 컴포넌트 (table-search-widget 등)
|
|
|
|
// 🆕 V2 컴포넌트 직접 감지 및 설정 패널 렌더링
|
|
if (componentId?.startsWith("v2-")) {
|
|
const v2ConfigPanels: Record<string, React.FC<{ config: any; onChange: (config: any) => void }>> = {
|
|
// V2 입력/선택 폐기 (2026-05-12) — input canonical 로 흡수. 하드코딩 매핑 제거.
|
|
// 옛 통합 목록 / repeater / 옛 표 형식 / v2-date 는 InvField · canonical table 등으로 흡수
|
|
// (ComponentRegistry fallback 으로 라우팅)
|
|
"v2-layout": require("@/components/v2/config-panels/V2LayoutConfigPanel").V2LayoutConfigPanel,
|
|
"v2-group": require("@/components/v2/config-panels/V2GroupConfigPanel").V2GroupConfigPanel,
|
|
// v2-media — Phase D.5 폐기. canonical input (FilePicker) 으로 흡수.
|
|
"v2-biz": require("@/components/v2/config-panels/V2BizConfigPanel").V2BizConfigPanel,
|
|
"v2-hierarchy": require("@/components/v2/config-panels/V2HierarchyConfigPanel").V2HierarchyConfigPanel,
|
|
"v2-bom-item-editor": require("@/components/v2/config-panels/V2BomItemEditorConfigPanel")
|
|
.V2BomItemEditorConfigPanel,
|
|
"v2-bom-tree": require("@/components/v2/config-panels/V2BomTreeConfigPanel").V2BomTreeConfigPanel,
|
|
};
|
|
|
|
const V2ConfigPanel = v2ConfigPanels[componentId];
|
|
if (V2ConfigPanel) {
|
|
const currentConfig = selectedComponent.componentConfig || {};
|
|
const handleV2ConfigChange = (newConfig: any) => {
|
|
onUpdateProperty(selectedComponent.id, "componentConfig", { ...currentConfig, ...newConfig });
|
|
};
|
|
|
|
// 현재 화면의 테이블명 가져오기
|
|
const currentTableName = tables?.[0]?.tableName;
|
|
|
|
// 컴포넌트별 추가 props
|
|
const extraProps: Record<string, any> = {};
|
|
const resolvedTableName = (selectedComponent as any).tableName || currentTable?.tableName || currentTableName;
|
|
|
|
// 옛 통합 목록 extraProps 분기 폐기 (Phase F.8) — canonical table 로 흡수.
|
|
if (componentId === "v2-bom-item-editor" || componentId === "v2-bom-tree") {
|
|
extraProps.currentTableName = currentTableName;
|
|
extraProps.screenTableName = resolvedTableName;
|
|
}
|
|
|
|
return (
|
|
<div key={selectedComponent.id} className="space-y-4">
|
|
<V2ConfigPanel config={currentConfig} onChange={handleV2ConfigChange} {...extraProps} />
|
|
</div>
|
|
);
|
|
}
|
|
}
|
|
|
|
if (componentId) {
|
|
const registryComponentId = isTableLikeComponentType(componentId) ? "table" : componentId;
|
|
const definition = ComponentRegistry.getComponent(registryComponentId);
|
|
|
|
// ★ 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 반환 (리마운트 방지)
|
|
const config = currentConfig || (definition as any).defaultProps?.componentConfig || {};
|
|
|
|
const handlePanelConfigChange = (newConfig: any) => {
|
|
// 🔧 Partial 업데이트: 기존 componentConfig를 유지하면서 새 설정만 병합
|
|
const mergedConfig = {
|
|
...currentConfig, // 기존 설정 유지
|
|
...newConfig, // 새 설정 병합
|
|
};
|
|
onUpdateProperty(selectedComponent.id, "componentConfig", mergedConfig);
|
|
};
|
|
|
|
return (
|
|
<div key={selectedComponent.id} className="space-y-4">
|
|
<Suspense
|
|
fallback={
|
|
<div className="flex items-center justify-center py-8">
|
|
<div className="text-muted-foreground text-sm">설정 패널 로딩 중...</div>
|
|
</div>
|
|
}
|
|
>
|
|
<ConfigPanelComponent
|
|
config={config}
|
|
onChange={handlePanelConfigChange}
|
|
onConfigChange={handlePanelConfigChange}
|
|
tables={normalizeConfigPanelTables(tables)}
|
|
allTables={allTables.length > 0 ? allTables : normalizeConfigPanelTables(tables)}
|
|
screenTableName={(selectedComponent as any).tableName || currentTable?.tableName || currentTableName}
|
|
tableName={(selectedComponent as any).tableName || currentTable?.tableName || currentTableName}
|
|
currentTableName={currentTableName}
|
|
columnName={
|
|
(selectedComponent as any).columnName || currentConfig?.columnName || currentConfig?.fieldName
|
|
}
|
|
inputType={(selectedComponent as any).inputType || currentConfig?.inputType}
|
|
componentType={componentType}
|
|
tableColumns={(currentTable as any)?.columns || []}
|
|
allComponents={allComponents}
|
|
currentComponent={selectedComponent}
|
|
menuObjid={menuObjid}
|
|
screenComponents={allComponents.map((comp: any) => ({
|
|
id: comp.id,
|
|
componentType: comp.componentType || comp.type,
|
|
label: comp.label || comp.name || comp.id,
|
|
tableName: comp.componentConfig?.tableName || comp.tableName,
|
|
columnName: comp.columnName || comp.componentConfig?.columnName || comp.componentConfig?.fieldName,
|
|
}))}
|
|
/>
|
|
</Suspense>
|
|
</div>
|
|
);
|
|
}
|
|
// ConfigPanel이 없으면 DynamicComponentConfigPanel fallback으로 처리
|
|
}
|
|
|
|
// DynamicComponentConfigPanel을 통한 동적 로드 (CONFIG_PANEL_MAP 기반)
|
|
const fallbackId = componentId || componentType;
|
|
if (fallbackId && hasComponentConfigPanel(fallbackId)) {
|
|
const handleDynamicConfigChange = (newConfig: any) => {
|
|
const currentConfig = selectedComponent.componentConfig || {};
|
|
const mergedConfig = { ...currentConfig, ...newConfig };
|
|
onUpdateProperty(selectedComponent.id, "componentConfig", mergedConfig);
|
|
};
|
|
|
|
return (
|
|
<DynamicComponentConfigPanel
|
|
componentId={fallbackId}
|
|
componentType={componentType}
|
|
config={selectedComponent.componentConfig || {}}
|
|
onChange={handleDynamicConfigChange}
|
|
screenTableName={(selectedComponent as any).tableName || currentTable?.tableName || currentTableName}
|
|
tableColumns={(currentTable as any)?.columns || []}
|
|
tables={tables}
|
|
menuObjid={menuObjid}
|
|
allComponents={allComponents}
|
|
currentComponent={selectedComponent}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex h-full flex-col items-center justify-center p-6 text-center">
|
|
<Settings className="text-muted-foreground mb-4 h-12 w-12" />
|
|
<h3 className="mb-2 text-base font-medium">설정 패널 없음</h3>
|
|
<p className="text-muted-foreground text-sm">
|
|
컴포넌트 "{fallbackId || componentType}"에 대한 설정 패널이 없습니다.
|
|
</p>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// 기본 정보 탭
|
|
const renderBasicTab = () => {
|
|
const widget = selectedComponent as WidgetComponent;
|
|
const group = selectedComponent as GroupComponent;
|
|
const area = selectedComponent as AreaComponent;
|
|
|
|
// 라벨 설정이 표시될 입력 필드 타입들
|
|
const inputFieldTypes = [
|
|
"text",
|
|
"number",
|
|
"decimal",
|
|
"date",
|
|
"datetime",
|
|
"time",
|
|
"email",
|
|
"tel",
|
|
"url",
|
|
"password",
|
|
"textarea",
|
|
"select",
|
|
"dropdown",
|
|
"entity",
|
|
"code",
|
|
"checkbox",
|
|
"radio",
|
|
"boolean",
|
|
"file",
|
|
"autocomplete",
|
|
"entity-search-input",
|
|
"autocomplete-search-input",
|
|
// 입력 6종(text/number/date/select/checkbox/textarea) 은 Phase E,
|
|
// radio-basic/toggle-switch 는 Phase F.1 에서 canonical input 으로 흡수,
|
|
// 목록에서 제거됨.
|
|
// 새로운 통합 입력 컴포넌트 (옛 V2 입력/선택은 input canonical 로 흡수되어 목록에서 제거됨)
|
|
"input",
|
|
"v2-entity-select",
|
|
];
|
|
|
|
// 현재 컴포넌트가 입력 필드인지 확인
|
|
const componentType = widget.widgetType || (widget as any).componentId || (widget as any).componentType;
|
|
const isInputField = inputFieldTypes.includes(componentType);
|
|
|
|
return (
|
|
<div className="space-y-1">
|
|
<div style={{ padding: "0 12px" }}>
|
|
<CPGroup title="외형" defaultOpen>
|
|
{/* DIMENSIONS → 크기 (CPSection + CPRow) */}
|
|
<div>
|
|
<CPSection title="크기">
|
|
<CPRow label="너비">
|
|
<CPText
|
|
type="number"
|
|
mono
|
|
value={localWidth}
|
|
onChange={(v) => setLocalWidth(v)}
|
|
onBlur={(e) => {
|
|
const value = parseInt((e.target as HTMLInputElement).value) || 0;
|
|
if (value >= 10) {
|
|
const snappedValue = Math.round(value / 10) * 10;
|
|
handleUpdate("size.width", snappedValue);
|
|
setLocalWidth(String(snappedValue));
|
|
}
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") {
|
|
const value = parseInt((e.currentTarget as HTMLInputElement).value) || 0;
|
|
if (value >= 10) {
|
|
const snappedValue = Math.round(value / 10) * 10;
|
|
handleUpdate("size.width", snappedValue);
|
|
setLocalWidth(String(snappedValue));
|
|
}
|
|
(e.currentTarget as HTMLInputElement).blur();
|
|
}
|
|
}}
|
|
placeholder="100"
|
|
suffix="px"
|
|
/>
|
|
</CPRow>
|
|
<CPRow label="높이">
|
|
<CPText
|
|
type="number"
|
|
mono
|
|
value={localHeight}
|
|
onChange={(v) => setLocalHeight(v)}
|
|
onBlur={(e) => {
|
|
const value = parseInt((e.target as HTMLInputElement).value) || 0;
|
|
if (value >= 10) {
|
|
const snappedValue = Math.round(value / 10) * 10;
|
|
handleUpdate("size.height", snappedValue);
|
|
setLocalHeight(String(snappedValue));
|
|
}
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") {
|
|
const value = parseInt((e.currentTarget as HTMLInputElement).value) || 0;
|
|
if (value >= 10) {
|
|
const snappedValue = Math.round(value / 10) * 10;
|
|
handleUpdate("size.height", snappedValue);
|
|
setLocalHeight(String(snappedValue));
|
|
}
|
|
(e.currentTarget as HTMLInputElement).blur();
|
|
}
|
|
}}
|
|
placeholder="10"
|
|
suffix="px"
|
|
/>
|
|
</CPRow>
|
|
<CPRow label="Z-Index">
|
|
<CPNumber
|
|
value={currentPosition.z || 1}
|
|
onChange={(v) => handleUpdate("position.z", v ?? 1)}
|
|
/>
|
|
</CPRow>
|
|
</CPSection>
|
|
</div>
|
|
|
|
{/* CONTENT → 내용 (group/area) */}
|
|
{(selectedComponent.type === "group" || selectedComponent.type === "area") && (
|
|
<div>
|
|
<CPSection title="내용">
|
|
<CPRow label="제목">
|
|
<CPText
|
|
value={(group as any).title || (area as any).title || ""}
|
|
onChange={(v) => handleUpdate("title", v)}
|
|
placeholder="제목"
|
|
/>
|
|
</CPRow>
|
|
{selectedComponent.type === "area" && (
|
|
<CPRow label="설명">
|
|
<CPText
|
|
value={(area as any).description || ""}
|
|
onChange={(v) => handleUpdate("description", v)}
|
|
placeholder="설명"
|
|
/>
|
|
</CPRow>
|
|
)}
|
|
</CPSection>
|
|
</div>
|
|
)}
|
|
|
|
{/* OPTIONS → 옵션 (CPRow + CPSwitch) */}
|
|
<div>
|
|
<CPSection title="옵션">
|
|
{(isInputField || widget.required !== undefined) &&
|
|
(() => {
|
|
const colName = widget.columnName || (selectedComponent as any)?.columnName;
|
|
const colMeta = colName
|
|
? (currentTable as any)?.columns?.find(
|
|
(c: any) => (c.columnName || c.column_name || "").toLowerCase() === colName.toLowerCase(),
|
|
)
|
|
: null;
|
|
const isNotNull =
|
|
colMeta &&
|
|
((colMeta as any).isNullable === "NO" ||
|
|
(colMeta as any).isNullable === "N" ||
|
|
(colMeta as any).is_nullable === "NO" ||
|
|
(colMeta as any).is_nullable === "N");
|
|
return (
|
|
<CPRow
|
|
label={
|
|
<>
|
|
필수
|
|
{isNotNull && (
|
|
<span style={{ marginLeft: 4, fontSize: 9, color: "var(--v5-text-muted)" }}>
|
|
(NOT NULL)
|
|
</span>
|
|
)}
|
|
</>
|
|
}
|
|
>
|
|
<CPSwitch
|
|
value={
|
|
isNotNull || widget.required === true || selectedComponent.componentConfig?.required === true
|
|
}
|
|
onChange={(checked) => {
|
|
if (isNotNull) return;
|
|
handleUpdate("required", checked);
|
|
handleUpdate("componentConfig.required", checked);
|
|
}}
|
|
/>
|
|
</CPRow>
|
|
);
|
|
})()}
|
|
{(isInputField || widget.readonly !== undefined) && (
|
|
<CPRow label="읽기전용">
|
|
<CPSwitch
|
|
value={widget.readonly === true || selectedComponent.componentConfig?.readonly === true}
|
|
onChange={(checked) => {
|
|
handleUpdate("readonly", checked);
|
|
handleUpdate("componentConfig.readonly", checked);
|
|
}}
|
|
/>
|
|
</CPRow>
|
|
)}
|
|
<CPRow label="숨김">
|
|
<CPSwitch
|
|
value={(selectedComponent as any).hidden === true || selectedComponent.componentConfig?.hidden === true}
|
|
onChange={(checked) => {
|
|
handleUpdate("hidden", checked);
|
|
handleUpdate("componentConfig.hidden", checked);
|
|
}}
|
|
/>
|
|
</CPRow>
|
|
</CPSection>
|
|
</div>
|
|
|
|
{/* LABEL → 라벨 (nested CPGroup) */}
|
|
{isInputField && (
|
|
<div>
|
|
<CPGroup title="라벨" defaultOpen={false}>
|
|
<CPRow label="텍스트">
|
|
<CPText
|
|
value={
|
|
selectedComponent.style?.labelText !== undefined
|
|
? selectedComponent.style.labelText
|
|
: selectedComponent.label || selectedComponent.componentConfig?.label || ""
|
|
}
|
|
onChange={(v) => {
|
|
handleUpdate("style.labelText", v);
|
|
handleUpdate("label", v);
|
|
}}
|
|
placeholder="라벨"
|
|
/>
|
|
</CPRow>
|
|
<CPRow label="위치">
|
|
<Select
|
|
value={selectedComponent.style?.labelPosition || "top"}
|
|
onValueChange={(value) => handleUpdate("style.labelPosition", value)}
|
|
>
|
|
<SelectTrigger className="h-7 text-xs">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="top">위</SelectItem>
|
|
<SelectItem value="bottom">아래</SelectItem>
|
|
<SelectItem value="left">왼쪽</SelectItem>
|
|
<SelectItem value="right">오른쪽</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</CPRow>
|
|
<CPRow label="간격">
|
|
<CPText
|
|
mono
|
|
value={
|
|
selectedComponent.style?.labelPosition === "left" ||
|
|
selectedComponent.style?.labelPosition === "right"
|
|
? selectedComponent.style?.labelGap || "8px"
|
|
: selectedComponent.style?.labelMarginBottom || "4px"
|
|
}
|
|
onChange={(v) => {
|
|
const pos = selectedComponent.style?.labelPosition;
|
|
if (pos === "left" || pos === "right") {
|
|
handleUpdate("style.labelGap", v);
|
|
} else {
|
|
handleUpdate("style.labelMarginBottom", v);
|
|
}
|
|
}}
|
|
/>
|
|
</CPRow>
|
|
<CPRow label="크기">
|
|
<CPText
|
|
mono
|
|
value={selectedComponent.style?.labelFontSize || "12px"}
|
|
onChange={(v) => handleUpdate("style.labelFontSize", v)}
|
|
/>
|
|
</CPRow>
|
|
<CPRow label="색상">
|
|
<ColorPickerWithTransparent
|
|
value={selectedComponent.style?.labelColor}
|
|
onChange={(value) => handleUpdate("style.labelColor", value)}
|
|
defaultColor="#212121"
|
|
placeholder="#212121"
|
|
/>
|
|
</CPRow>
|
|
<CPRow label="굵기">
|
|
<Select
|
|
value={selectedComponent.style?.labelFontWeight || "500"}
|
|
onValueChange={(value) => handleUpdate("style.labelFontWeight", value)}
|
|
>
|
|
<SelectTrigger className="h-7 text-xs">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="400">보통</SelectItem>
|
|
<SelectItem value="500">중간</SelectItem>
|
|
<SelectItem value="600">굵게</SelectItem>
|
|
<SelectItem value="700">매우 굵게</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</CPRow>
|
|
<CPRow label="표시">
|
|
<CPSwitch
|
|
value={selectedComponent.style?.labelDisplay === true || (selectedComponent as any).labelDisplay === true}
|
|
onChange={(checked) => {
|
|
handleUpdate("style.labelDisplay", checked);
|
|
handleUpdate("labelDisplay", checked);
|
|
if (checked && !selectedComponent.style?.labelText) {
|
|
const labelValue = selectedComponent.label || selectedComponent.componentConfig?.label || "";
|
|
if (labelValue) {
|
|
handleUpdate("style.labelText", labelValue);
|
|
}
|
|
}
|
|
}}
|
|
/>
|
|
</CPRow>
|
|
</CPGroup>
|
|
</div>
|
|
)}
|
|
</CPGroup>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// 상세 설정 탭 (DetailSettingsPanel의 전체 로직 통합)
|
|
const renderDetailTab = () => {
|
|
// 1. DataTable 컴포넌트
|
|
if (selectedComponent.type === "datatable") {
|
|
return (
|
|
<DataTableConfigPanel
|
|
component={selectedComponent as DataTableComponent}
|
|
tables={tables}
|
|
onUpdateComponent={(updates) => {
|
|
Object.entries(updates).forEach(([key, value]) => {
|
|
handleUpdate(key, value);
|
|
});
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// 3. 파일 컴포넌트
|
|
if (isFileComponent(selectedComponent)) {
|
|
return (
|
|
<FileComponentConfigPanel
|
|
component={selectedComponent as FileComponent}
|
|
onUpdateProperty={onUpdateProperty}
|
|
currentTable={currentTable}
|
|
currentTableName={currentTableName}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// 🆕 3.5. V2 컴포넌트 - 반드시 다른 체크보다 먼저 처리
|
|
const v2ComponentType = (selectedComponent as any).componentType || selectedComponent.componentConfig?.type || "";
|
|
if (v2ComponentType.startsWith("v2-")) {
|
|
const configPanel = renderComponentConfigPanel();
|
|
if (configPanel) {
|
|
return <div className="space-y-4">{configPanel}</div>;
|
|
}
|
|
}
|
|
|
|
// 4. 새로운 컴포넌트 시스템 (button, card 등)
|
|
const componentType = selectedComponent.componentConfig?.type || selectedComponent.type;
|
|
const hasNewConfigPanel =
|
|
componentType &&
|
|
[
|
|
"button",
|
|
"button-primary",
|
|
"button-secondary",
|
|
"v2-button-primary",
|
|
"card",
|
|
"dashboard",
|
|
"stats",
|
|
"stats-card",
|
|
"progress",
|
|
"progress-bar",
|
|
"chart",
|
|
"chart-basic",
|
|
"alert",
|
|
"alert-info",
|
|
"badge",
|
|
"badge-status",
|
|
].includes(componentType);
|
|
|
|
if (hasNewConfigPanel) {
|
|
const configPanel = renderComponentConfigPanel();
|
|
if (configPanel) {
|
|
return <div className="space-y-4">{configPanel}</div>;
|
|
}
|
|
}
|
|
|
|
// 5. 새로운 컴포넌트 시스템 (type: "component")
|
|
if (selectedComponent.type === "component") {
|
|
const componentId = (selectedComponent as any).componentType || selectedComponent.componentConfig?.type;
|
|
const webType = selectedComponent.componentConfig?.webType;
|
|
|
|
// 테이블 패널에서 드래그한 컴포넌트인지 확인
|
|
const isFromTablePanel = !!((selectedComponent as any).tableName && (selectedComponent as any).columnName);
|
|
|
|
if (!componentId) {
|
|
return (
|
|
<div className="flex h-full items-center justify-center p-8 text-center">
|
|
<p className="text-muted-foreground text-sm">컴포넌트 ID가 설정되지 않았습니다</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 🆕 ComponentRegistry에서 전용 ConfigPanel이 있는지 먼저 확인
|
|
// ★ 2026-04-11: ComponentDefinition 은 config_panel (snake_case) 로 저장됨.
|
|
// 기존 코드는 configPanel (camelCase) 만 찾아서 항상 false. 둘 다 체크.
|
|
const registryComponentId = isTableLikeComponentType(componentId) ? "table" : componentId;
|
|
const definition = ComponentRegistry.getComponent(registryComponentId);
|
|
const configPanelFromDef =
|
|
(definition as any)?.configPanel ?? (definition as any)?.config_panel;
|
|
if (configPanelFromDef) {
|
|
// 전용 ConfigPanel이 있으면 renderComponentConfigPanel 호출
|
|
const configPanelContent = renderComponentConfigPanel();
|
|
if (configPanelContent) {
|
|
return configPanelContent;
|
|
}
|
|
}
|
|
|
|
// 현재 웹타입의 기본 입력 타입 추출
|
|
const currentBaseInputType = webType ? getBaseInputType(webType as any) : null;
|
|
|
|
// 선택 가능한 세부 타입 목록
|
|
const availableDetailTypes = currentBaseInputType ? getDetailTypes(currentBaseInputType) : [];
|
|
|
|
// 세부 타입 변경 핸들러
|
|
const handleDetailTypeChange = (newDetailType: string) => {
|
|
setLocalComponentDetailType(newDetailType);
|
|
handleUpdate("componentConfig.webType", newDetailType);
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* 세부 타입 선택 - 테이블 패널에서 드래그한 컴포넌트만 표시 */}
|
|
{isFromTablePanel && webType && availableDetailTypes.length > 1 && (
|
|
<div>
|
|
<Label>세부 타입</Label>
|
|
<Select value={localComponentDetailType || webType} onValueChange={handleDetailTypeChange}>
|
|
<SelectTrigger className="h-6 w-full px-2 py-0 text-xs">
|
|
<SelectValue placeholder="세부 타입 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{availableDetailTypes.map((option) => (
|
|
<SelectItem key={option.value} value={option.value}>
|
|
<div>
|
|
<div className="font-medium">{option.label}</div>
|
|
<div className="text-muted-foreground text-xs">{option.description}</div>
|
|
</div>
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
)}
|
|
|
|
{/* DynamicComponentConfigPanel */}
|
|
<DynamicComponentConfigPanel
|
|
componentId={componentId}
|
|
config={selectedComponent.componentConfig || {}}
|
|
screenTableName={(selectedComponent as any).tableName || currentTable?.tableName || currentTableName}
|
|
tableColumns={(currentTable as any)?.columns || []}
|
|
tables={tables}
|
|
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
|
|
allComponents={allComponents} // 🆕 연쇄 드롭다운 부모 감지용
|
|
currentComponent={selectedComponent} // 🆕 현재 컴포넌트 정보
|
|
onChange={(newConfig) => {
|
|
Object.entries(newConfig).forEach(([key, value]) => {
|
|
handleUpdate(`componentConfig.${key}`, value);
|
|
});
|
|
}}
|
|
/>
|
|
|
|
{/* 🆕 테이블 데이터 자동 입력 (component 타입용) */}
|
|
<Separator />
|
|
<div className="space-y-3">
|
|
<div className="flex items-center gap-2">
|
|
<Database className="text-primary h-4 w-4" />
|
|
<h4 className="text-xs font-semibold">테이블 데이터 자동 입력</h4>
|
|
</div>
|
|
|
|
{/* 활성화 체크박스 */}
|
|
<div className="flex items-center space-x-2">
|
|
<Checkbox
|
|
id="autoFill-enabled-component"
|
|
checked={selectedComponent.autoFill?.enabled || false}
|
|
onCheckedChange={(checked) => {
|
|
handleUpdate("autoFill", {
|
|
...selectedComponent.autoFill,
|
|
enabled: Boolean(checked),
|
|
});
|
|
}}
|
|
/>
|
|
<Label htmlFor="autoFill-enabled-component" className="cursor-pointer text-xs">
|
|
현재 사용자 정보로 테이블 조회하여 자동 입력
|
|
</Label>
|
|
</div>
|
|
|
|
{selectedComponent.autoFill?.enabled && (
|
|
<>
|
|
{/* 조회할 테이블 */}
|
|
<div className="space-y-1">
|
|
<Label htmlFor="autoFill-sourceTable-component" className="text-xs">
|
|
조회할 테이블 <span className="text-destructive">*</span>
|
|
</Label>
|
|
<Select
|
|
value={selectedComponent.autoFill?.sourceTable || ""}
|
|
onValueChange={(value) => {
|
|
handleUpdate("autoFill", {
|
|
...selectedComponent.autoFill,
|
|
enabled: selectedComponent.autoFill?.enabled || false,
|
|
sourceTable: value,
|
|
});
|
|
}}
|
|
>
|
|
<SelectTrigger className="h-6 w-full px-2 py-0 text-xs">
|
|
<SelectValue placeholder="테이블 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{tables.map((table) => (
|
|
<SelectItem key={table.tableName} value={table.tableName} className="text-xs">
|
|
{table.tableName}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* 필터링할 컬럼 */}
|
|
<div className="space-y-1">
|
|
<Label htmlFor="autoFill-filterColumn-component" className="text-xs">
|
|
필터링할 컬럼 <span className="text-destructive">*</span>
|
|
</Label>
|
|
<Input
|
|
id="autoFill-filterColumn-component"
|
|
value={selectedComponent.autoFill?.filterColumn || ""}
|
|
onChange={(e) => {
|
|
handleUpdate("autoFill", {
|
|
...selectedComponent.autoFill,
|
|
enabled: selectedComponent.autoFill?.enabled || false,
|
|
filterColumn: e.target.value,
|
|
});
|
|
}}
|
|
placeholder="예: company_code"
|
|
className="h-6 w-full px-2 py-0 text-xs"
|
|
/>
|
|
</div>
|
|
|
|
{/* 사용자 정보 필드 */}
|
|
<div className="space-y-1">
|
|
<Label htmlFor="autoFill-userField-component" className="text-xs">
|
|
사용자 정보 필드 <span className="text-destructive">*</span>
|
|
</Label>
|
|
<Select
|
|
value={selectedComponent.autoFill?.userField || ""}
|
|
onValueChange={(value: "companyCode" | "userId" | "deptCode") => {
|
|
handleUpdate("autoFill", {
|
|
...selectedComponent.autoFill,
|
|
enabled: selectedComponent.autoFill?.enabled || false,
|
|
userField: value,
|
|
});
|
|
}}
|
|
>
|
|
<SelectTrigger className="h-6 w-full px-2 py-0 text-xs">
|
|
<SelectValue placeholder="사용자 정보 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="companyCode" className="text-xs">
|
|
현재 로그인한 사용자 회사 코드
|
|
</SelectItem>
|
|
<SelectItem value="userId" className="text-xs">
|
|
현재 로그인한 사용자 ID
|
|
</SelectItem>
|
|
<SelectItem value="deptCode" className="text-xs">
|
|
현재 로그인한 사용자 부서 코드
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* 표시할 컬럼 */}
|
|
<div className="space-y-1">
|
|
<Label htmlFor="autoFill-displayColumn-component" className="text-xs">
|
|
표시할 컬럼 <span className="text-destructive">*</span>
|
|
</Label>
|
|
<Input
|
|
id="autoFill-displayColumn-component"
|
|
value={selectedComponent.autoFill?.displayColumn || ""}
|
|
onChange={(e) => {
|
|
handleUpdate("autoFill", {
|
|
...selectedComponent.autoFill,
|
|
enabled: selectedComponent.autoFill?.enabled || false,
|
|
displayColumn: e.target.value,
|
|
});
|
|
}}
|
|
placeholder="예: company_name"
|
|
className="h-6 w-full px-2 py-0 text-xs"
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 6. Widget 컴포넌트
|
|
if (selectedComponent.type === "widget") {
|
|
const widget = selectedComponent as WidgetComponent;
|
|
|
|
// 새로운 컴포넌트 시스템 (widgetType이 button, card 등) - 먼저 체크
|
|
if (
|
|
widget.widgetType &&
|
|
["button", "card", "dashboard", "stats-card", "progress-bar", "chart", "alert", "badge"].includes(
|
|
widget.widgetType,
|
|
)
|
|
) {
|
|
return (
|
|
<DynamicComponentConfigPanel
|
|
componentId={widget.widgetType}
|
|
config={widget.componentConfig || {}}
|
|
screenTableName={(widget as any).tableName || currentTable?.tableName || currentTableName}
|
|
tableColumns={(currentTable as any)?.columns || []}
|
|
tables={tables}
|
|
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
|
|
allComponents={allComponents} // 🆕 연쇄 드롭다운 부모 감지용
|
|
currentComponent={selectedComponent} // 🆕 현재 컴포넌트 정보
|
|
onChange={(newConfig) => {
|
|
handleUpdate("componentConfig", newConfig);
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// 일반 위젯 (webType 기반)
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* WebType 선택 (있는 경우만) */}
|
|
{(widget as any).webType && (
|
|
<div>
|
|
<Label>입력 타입</Label>
|
|
<Select value={(widget as any).webType} onValueChange={(value) => handleUpdate("webType", value)}>
|
|
<SelectTrigger className="h-6 w-full px-2 py-0 text-xs">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{webTypes.map((wt) => (
|
|
<SelectItem key={wt.web_type} value={wt.web_type}>
|
|
{(wt as any).web_type_name_kor || wt.web_type}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
)}
|
|
|
|
{/* 🆕 테이블 데이터 자동 입력 (모든 widget 컴포넌트) */}
|
|
<Separator />
|
|
<div className="border-destructive space-y-3 border-4 bg-amber-100 p-4">
|
|
<div className="flex items-center gap-2">
|
|
<Database className="text-primary h-4 w-4" />
|
|
<h4 className="text-xs font-semibold">테이블 데이터 자동 입력</h4>
|
|
</div>
|
|
|
|
{/* 활성화 체크박스 */}
|
|
<div className="flex items-center space-x-2">
|
|
<Checkbox
|
|
id="autoFill-enabled"
|
|
checked={widget.autoFill?.enabled || false}
|
|
onCheckedChange={(checked) => {
|
|
handleUpdate("autoFill", {
|
|
...widget.autoFill,
|
|
enabled: Boolean(checked),
|
|
});
|
|
}}
|
|
/>
|
|
<Label htmlFor="autoFill-enabled" className="cursor-pointer text-xs">
|
|
현재 사용자 정보로 테이블 조회하여 자동 입력
|
|
</Label>
|
|
</div>
|
|
|
|
{widget.autoFill?.enabled && (
|
|
<>
|
|
{/* 조회할 테이블 */}
|
|
<div className="space-y-1">
|
|
<Label htmlFor="autoFill-sourceTable" className="text-xs">
|
|
조회할 테이블 <span className="text-destructive">*</span>
|
|
</Label>
|
|
<Select
|
|
value={widget.autoFill?.sourceTable || ""}
|
|
onValueChange={(value) => {
|
|
handleUpdate("autoFill", {
|
|
...widget.autoFill,
|
|
enabled: widget.autoFill?.enabled || false,
|
|
sourceTable: value,
|
|
});
|
|
}}
|
|
>
|
|
<SelectTrigger className="h-6 w-full px-2 py-0 text-xs">
|
|
<SelectValue placeholder="테이블 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{tables.map((table) => (
|
|
<SelectItem key={table.tableName} value={table.tableName} className="text-xs">
|
|
{table.tableName}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* 필터링할 컬럼 */}
|
|
<div className="space-y-1">
|
|
<Label htmlFor="autoFill-filterColumn" className="text-xs">
|
|
필터링할 컬럼 <span className="text-destructive">*</span>
|
|
</Label>
|
|
<Input
|
|
id="autoFill-filterColumn"
|
|
value={widget.autoFill?.filterColumn || ""}
|
|
onChange={(e) => {
|
|
handleUpdate("autoFill", {
|
|
...widget.autoFill,
|
|
enabled: widget.autoFill?.enabled || false,
|
|
filterColumn: e.target.value,
|
|
});
|
|
}}
|
|
placeholder="예: company_code"
|
|
className="h-6 w-full px-2 py-0 text-xs"
|
|
/>
|
|
</div>
|
|
|
|
{/* 사용자 정보 필드 */}
|
|
<div className="space-y-1">
|
|
<Label htmlFor="autoFill-userField" className="text-xs">
|
|
사용자 정보 필드 <span className="text-destructive">*</span>
|
|
</Label>
|
|
<Select
|
|
value={widget.autoFill?.userField || ""}
|
|
onValueChange={(value: "companyCode" | "userId" | "deptCode") => {
|
|
handleUpdate("autoFill", {
|
|
...widget.autoFill,
|
|
enabled: widget.autoFill?.enabled || false,
|
|
userField: value,
|
|
});
|
|
}}
|
|
>
|
|
<SelectTrigger className="h-6 w-full px-2 py-0 text-xs">
|
|
<SelectValue placeholder="사용자 정보 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="companyCode" className="text-xs">
|
|
현재 로그인한 사용자 회사 코드
|
|
</SelectItem>
|
|
<SelectItem value="userId" className="text-xs">
|
|
현재 로그인한 사용자 ID
|
|
</SelectItem>
|
|
<SelectItem value="deptCode" className="text-xs">
|
|
현재 로그인한 사용자 부서 코드
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* 표시할 컬럼 */}
|
|
<div className="space-y-1">
|
|
<Label htmlFor="autoFill-displayColumn" className="text-xs">
|
|
표시할 컬럼 <span className="text-destructive">*</span>
|
|
</Label>
|
|
<Input
|
|
id="autoFill-displayColumn"
|
|
value={widget.autoFill?.displayColumn || ""}
|
|
onChange={(e) => {
|
|
handleUpdate("autoFill", {
|
|
...widget.autoFill,
|
|
enabled: widget.autoFill?.enabled || false,
|
|
displayColumn: e.target.value,
|
|
});
|
|
}}
|
|
placeholder="예: company_name"
|
|
className="h-6 w-full px-2 py-0 text-xs"
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 기본 메시지
|
|
return (
|
|
<div className="flex h-full items-center justify-center p-8 text-center">
|
|
<p className="text-muted-foreground text-sm">이 컴포넌트는 추가 설정이 없습니다</p>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className="flex h-full flex-col">
|
|
{/* 통합 컨텐츠 (탭 제거) */}
|
|
<div className="flex-1 overflow-x-auto overflow-y-auto p-2">
|
|
<div className="space-y-4 text-xs">
|
|
{/* 1. 컴포넌트 정체 (brumb) + ① 데이터 소속 + ② 유형별 설정 + 고급 설정 */}
|
|
{renderDetailTab()}
|
|
|
|
{/* 2. 외형 (라벨 + 크기 + 옵션) — CPGroup 으로 묶음 */}
|
|
{renderBasicTab()}
|
|
|
|
{/* 조건부 표시 설정 */}
|
|
{selectedComponent && (
|
|
<>
|
|
<div style={{ padding: "0 12px" }}>
|
|
<CPGroup title="조건부 표시" defaultOpen={false}>
|
|
<ConditionalConfigPanel
|
|
config={
|
|
(selectedComponent as any).conditional || {
|
|
enabled: false,
|
|
field: "",
|
|
operator: "=",
|
|
value: "",
|
|
action: "show",
|
|
}
|
|
}
|
|
onChange={(newConfig: ConditionalConfig | undefined) => {
|
|
handleUpdate("conditional", newConfig);
|
|
}}
|
|
availableFields={
|
|
allComponents
|
|
?.filter((c) => {
|
|
// 자기 자신 제외
|
|
if (c.id === selectedComponent.id) return false;
|
|
// widget 타입 또는 component 타입 (V2 컴포넌트 포함)
|
|
return c.type === "widget" || c.type === "component";
|
|
})
|
|
.map((c) => {
|
|
const widgetType = (c as any).widgetType || (c as any).componentType || "text";
|
|
const config = (c as any).componentConfig || (c as any).webTypeConfig || {};
|
|
const detailSettings = (c as any).detailSettings || {};
|
|
|
|
// 정적 옵션 추출 (select, dropdown, radio, entity 등)
|
|
let options: Array<{ value: string; label: string }> | undefined;
|
|
|
|
// V2 컴포넌트의 경우
|
|
if (config.options && Array.isArray(config.options)) {
|
|
options = config.options;
|
|
}
|
|
// 레거시 컴포넌트의 경우
|
|
else if ((c as any).options && Array.isArray((c as any).options)) {
|
|
options = (c as any).options;
|
|
}
|
|
|
|
// 엔티티 정보 추출 (config > detailSettings > 직접 속성 순으로 우선순위)
|
|
const entityTable =
|
|
config.entityTable ||
|
|
detailSettings.referenceTable ||
|
|
(c as any).entityTable ||
|
|
(c as any).referenceTable;
|
|
const entityValueColumn =
|
|
config.entityValueColumn ||
|
|
detailSettings.referenceColumn ||
|
|
(c as any).entityValueColumn ||
|
|
(c as any).referenceColumn;
|
|
const entityLabelColumn =
|
|
config.entityLabelColumn ||
|
|
detailSettings.displayColumn ||
|
|
(c as any).entityLabelColumn ||
|
|
(c as any).displayColumn;
|
|
|
|
// 공통코드 정보 추출
|
|
const codeGroup = config.codeGroup || detailSettings.codeGroup || (c as any).codeGroup;
|
|
|
|
return {
|
|
id: (c as any).columnName || c.id,
|
|
label: (c as any).label || config.label || c.id,
|
|
type: widgetType,
|
|
options,
|
|
entityTable,
|
|
entityValueColumn,
|
|
entityLabelColumn,
|
|
codeGroup,
|
|
};
|
|
}) || []
|
|
}
|
|
currentComponentId={selectedComponent.id}
|
|
/>
|
|
</CPGroup>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* 스타일 설정 */}
|
|
{selectedComponent && (
|
|
<>
|
|
<div style={{ padding: "0 12px" }}>
|
|
<CPGroup title="컴포넌트 스타일" defaultOpen={false}>
|
|
<StyleEditor
|
|
style={selectedComponent.style || {}}
|
|
onStyleChange={(style) => {
|
|
if (onStyleChange) {
|
|
onStyleChange(style);
|
|
} else {
|
|
handleUpdate("style", style);
|
|
}
|
|
}}
|
|
/>
|
|
</CPGroup>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default V2PropertiesPanel;
|