- Added a new function `isColumnRequired` to determine if a column is required based on its NOT NULL status from the table schema. - Updated the `SaveModal` and `InteractiveScreenViewer` components to incorporate this validation, ensuring that required fields are accurately assessed during form submission. - Enhanced the `DynamicComponentRenderer` to reflect the NOT NULL requirement in the component's required state. - Improved user feedback by marking required fields with an asterisk based on both manual settings and database constraints. These changes enhance the form validation process, ensuring that users are prompted for all necessary information based on the underlying data structure.
1624 lines
68 KiB
TypeScript
1624 lines
68 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 { Textarea } from "@/components/ui/textarea";
|
||
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,
|
||
LayoutComponent,
|
||
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 } from "@/lib/utils/componentTypeUtils";
|
||
import {
|
||
BaseInputType,
|
||
BASE_INPUT_TYPE_OPTIONS,
|
||
getBaseInputType,
|
||
getDefaultDetailType,
|
||
getDetailTypes,
|
||
DetailTypeOption,
|
||
} from "@/types/input-type-mapping";
|
||
|
||
// 새로운 컴포넌트 설정 패널들
|
||
import { ButtonConfigPanel } from "../config-panels/ButtonConfigPanel";
|
||
import { CardConfigPanel } from "../config-panels/CardConfigPanel";
|
||
import { DashboardConfigPanel } from "../config-panels/DashboardConfigPanel";
|
||
import { ColorPickerWithTransparent } from "../common/ColorPickerWithTransparent";
|
||
import { StatsCardConfigPanel } from "../config-panels/StatsCardConfigPanel";
|
||
|
||
// ComponentRegistry import (동적 ConfigPanel 가져오기용)
|
||
import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";
|
||
import { ProgressBarConfigPanel } from "../config-panels/ProgressBarConfigPanel";
|
||
import { ChartConfigPanel } from "../config-panels/ChartConfigPanel";
|
||
import { AlertConfigPanel } from "../config-panels/AlertConfigPanel";
|
||
import { BadgeConfigPanel } from "../config-panels/BadgeConfigPanel";
|
||
import { DynamicComponentConfigPanel } from "@/lib/utils/getComponentConfigPanel";
|
||
import StyleEditor from "../StyleEditor";
|
||
import { Slider } from "@/components/ui/slider";
|
||
import { Zap } from "lucide-react";
|
||
import { ConditionalConfigPanel } from "@/components/v2/ConditionalConfigPanel";
|
||
import { ConditionalConfig } from "@/types/v2-components";
|
||
|
||
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<Array<{ tableName: string; displayName?: string }>>([]);
|
||
|
||
// 🆕 전체 테이블 목록 로드
|
||
useEffect(() => {
|
||
const loadAllTables = async () => {
|
||
try {
|
||
const { tableManagementApi } = await import("@/lib/api/tableManagement");
|
||
const response = await tableManagementApi.getTableList();
|
||
if (response.success && response.data) {
|
||
setAllTables(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 bg-white">
|
||
<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;
|
||
|
||
const handleUpdateProperty = (path: string, value: any) => {
|
||
onUpdateProperty(selectedComponent.id, path, value);
|
||
};
|
||
|
||
const handleConfigChange = (newConfig: any) => {
|
||
// 기존 config와 병합하여 다른 속성 유지
|
||
const currentConfig = selectedComponent.componentConfig?.config || {};
|
||
const mergedConfig = { ...currentConfig, ...newConfig };
|
||
onUpdateProperty(selectedComponent.id, "componentConfig.config", mergedConfig);
|
||
};
|
||
|
||
// 🆕 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-input": require("@/components/v2/config-panels/V2InputConfigPanel").V2InputConfigPanel,
|
||
"v2-select": require("@/components/v2/config-panels/V2SelectConfigPanel").V2SelectConfigPanel,
|
||
"v2-date": require("@/components/v2/config-panels/V2DateConfigPanel").V2DateConfigPanel,
|
||
"v2-list": require("@/components/v2/config-panels/V2ListConfigPanel").V2ListConfigPanel,
|
||
"v2-layout": require("@/components/v2/config-panels/V2LayoutConfigPanel").V2LayoutConfigPanel,
|
||
"v2-group": require("@/components/v2/config-panels/V2GroupConfigPanel").V2GroupConfigPanel,
|
||
"v2-media": require("@/components/v2/config-panels/V2MediaConfigPanel").V2MediaConfigPanel,
|
||
"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 });
|
||
};
|
||
|
||
// 컬럼의 inputType 가져오기 (entity 타입인지 확인용)
|
||
const inputType = currentConfig.inputType || currentConfig.webType || (selectedComponent as any).inputType;
|
||
|
||
// 현재 화면의 테이블명 가져오기
|
||
const currentTableName = tables?.[0]?.tableName;
|
||
|
||
// 컴포넌트별 추가 props
|
||
const extraProps: Record<string, any> = {};
|
||
if (componentId === "v2-select") {
|
||
extraProps.inputType = inputType;
|
||
extraProps.tableName = selectedComponent.tableName || currentTable?.tableName || currentTableName;
|
||
extraProps.columnName = selectedComponent.columnName || currentConfig.fieldKey || currentConfig.columnName;
|
||
}
|
||
if (componentId === "v2-list") {
|
||
extraProps.currentTableName = currentTableName;
|
||
}
|
||
if (componentId === "v2-bom-item-editor" || componentId === "v2-bom-tree") {
|
||
extraProps.currentTableName = currentTableName;
|
||
extraProps.screenTableName = selectedComponent.tableName || currentTable?.tableName || currentTableName;
|
||
}
|
||
|
||
return (
|
||
<div key={selectedComponent.id} className="space-y-4">
|
||
<V2ConfigPanel config={currentConfig} onChange={handleV2ConfigChange} {...extraProps} />
|
||
</div>
|
||
);
|
||
}
|
||
}
|
||
|
||
if (componentId) {
|
||
const definition = ComponentRegistry.getComponent(componentId);
|
||
|
||
if (definition?.configPanel) {
|
||
const ConfigPanelComponent = definition.configPanel;
|
||
const currentConfig = selectedComponent.componentConfig || {};
|
||
|
||
console.log("✅ ConfigPanel 표시:", {
|
||
componentId,
|
||
definitionName: definition.name,
|
||
hasConfigPanel: !!definition.configPanel,
|
||
currentConfig,
|
||
defaultSort: currentConfig?.defaultSort, // 🔍 defaultSort 확인
|
||
});
|
||
|
||
// 🔧 ConfigPanelWrapper를 인라인 함수 대신 직접 JSX 반환 (리마운트 방지)
|
||
const config = currentConfig || definition.defaultProps?.componentConfig || {};
|
||
|
||
const handlePanelConfigChange = (newConfig: any) => {
|
||
// 🔧 Partial 업데이트: 기존 componentConfig를 유지하면서 새 설정만 병합
|
||
const mergedConfig = {
|
||
...currentConfig, // 기존 설정 유지
|
||
...newConfig, // 새 설정 병합
|
||
};
|
||
console.log("🔧 [ConfigPanel] handleConfigChange:", {
|
||
componentId: selectedComponent.id,
|
||
currentConfig,
|
||
newConfig,
|
||
mergedConfig,
|
||
});
|
||
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} // 🔧 autocomplete-search-input 등 일부 컴포넌트용
|
||
tables={tables} // 테이블 정보 전달
|
||
allTables={allTables} // 🆕 전체 테이블 목록 전달 (selected-items-detail-input 등에서 사용)
|
||
screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName} // 🔧 화면 테이블명 전달
|
||
tableColumns={currentTable?.columns || []} // 🔧 테이블 컬럼 정보 전달
|
||
allComponents={allComponents} // 🆕 연쇄 드롭다운 부모 감지용
|
||
currentComponent={selectedComponent} // 🆕 현재 컴포넌트 정보
|
||
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달 (채번 규칙 등)
|
||
// 🆕 집계 위젯 등에서 사용하는 컴포넌트 목록
|
||
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이 없으면 아래 switch case로 넘어감
|
||
}
|
||
|
||
// 기존 하드코딩된 설정 패널들 (레거시)
|
||
switch (componentType) {
|
||
case "button":
|
||
case "button-primary":
|
||
case "button-secondary":
|
||
case "v2-button-primary":
|
||
// 🔧 component.id만 key로 사용 (unmount 방지)
|
||
return (
|
||
<ButtonConfigPanel
|
||
key={selectedComponent.id}
|
||
component={selectedComponent}
|
||
onUpdateProperty={handleUpdateProperty}
|
||
allComponents={allComponents}
|
||
currentTableName={currentTableName}
|
||
currentScreenCompanyCode={currentScreenCompanyCode}
|
||
/>
|
||
);
|
||
|
||
case "card":
|
||
return <CardConfigPanel component={selectedComponent} onUpdateProperty={handleUpdateProperty} />;
|
||
|
||
case "dashboard":
|
||
return <DashboardConfigPanel component={selectedComponent} onUpdateProperty={handleUpdateProperty} />;
|
||
|
||
case "stats":
|
||
case "stats-card":
|
||
return <StatsCardConfigPanel component={selectedComponent} onUpdateProperty={handleUpdateProperty} />;
|
||
|
||
case "progress":
|
||
case "progress-bar":
|
||
return <ProgressBarConfigPanel component={selectedComponent} onUpdateProperty={handleUpdateProperty} />;
|
||
|
||
case "chart":
|
||
case "chart-basic":
|
||
return <ChartConfigPanel component={selectedComponent} onUpdateProperty={handleUpdateProperty} />;
|
||
|
||
case "alert":
|
||
case "alert-info":
|
||
return <AlertConfigPanel component={selectedComponent} onUpdateProperty={handleUpdateProperty} />;
|
||
|
||
case "badge":
|
||
case "badge-status":
|
||
return <BadgeConfigPanel component={selectedComponent} onUpdateProperty={handleUpdateProperty} />;
|
||
|
||
case "section-card":
|
||
return (
|
||
<div className="space-y-4 p-4">
|
||
<div className="space-y-2">
|
||
<h3 className="text-sm font-semibold">Section Card 설정</h3>
|
||
<p className="text-muted-foreground text-xs">제목과 테두리가 있는 명확한 그룹화 컨테이너</p>
|
||
</div>
|
||
|
||
{/* 헤더 표시 */}
|
||
<div className="flex items-center space-x-2">
|
||
<Checkbox
|
||
id="showHeader"
|
||
checked={selectedComponent.componentConfig?.showHeader !== false}
|
||
onCheckedChange={(checked) => {
|
||
handleUpdateProperty(selectedComponent.id, "componentConfig.showHeader", checked);
|
||
}}
|
||
/>
|
||
<Label htmlFor="showHeader" className="cursor-pointer text-xs">
|
||
헤더 표시
|
||
</Label>
|
||
</div>
|
||
|
||
{/* 제목 */}
|
||
{selectedComponent.componentConfig?.showHeader !== false && (
|
||
<div className="space-y-2">
|
||
<Label className="text-xs">제목</Label>
|
||
<Input
|
||
value={selectedComponent.componentConfig?.title || ""}
|
||
onChange={(e) => {
|
||
handleUpdateProperty(selectedComponent.id, "componentConfig.title", e.target.value);
|
||
}}
|
||
placeholder="섹션 제목 입력"
|
||
className="h-9 text-xs"
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* 설명 */}
|
||
{selectedComponent.componentConfig?.showHeader !== false && (
|
||
<div className="space-y-2">
|
||
<Label className="text-xs">설명 (선택)</Label>
|
||
<Textarea
|
||
value={selectedComponent.componentConfig?.description || ""}
|
||
onChange={(e) => {
|
||
handleUpdateProperty(selectedComponent.id, "componentConfig.description", e.target.value);
|
||
}}
|
||
placeholder="섹션 설명 입력"
|
||
className="resize-none text-xs"
|
||
rows={2}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* 패딩 */}
|
||
<div className="space-y-2">
|
||
<Label className="text-xs">내부 여백</Label>
|
||
<Select
|
||
value={selectedComponent.componentConfig?.padding || "md"}
|
||
onValueChange={(value) => {
|
||
handleUpdateProperty(selectedComponent.id, "componentConfig.padding", value);
|
||
}}
|
||
>
|
||
<SelectTrigger className="h-9 text-xs">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="none">없음</SelectItem>
|
||
<SelectItem value="sm">작게 (12px)</SelectItem>
|
||
<SelectItem value="md">중간 (24px)</SelectItem>
|
||
<SelectItem value="lg">크게 (32px)</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
{/* 배경색 */}
|
||
<div className="space-y-2">
|
||
<Label className="text-xs">배경색</Label>
|
||
<Select
|
||
value={selectedComponent.componentConfig?.backgroundColor || "default"}
|
||
onValueChange={(value) => {
|
||
handleUpdateProperty(selectedComponent.id, "componentConfig.backgroundColor", value);
|
||
}}
|
||
>
|
||
<SelectTrigger className="h-9 text-xs">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="default">기본 (카드)</SelectItem>
|
||
<SelectItem value="muted">회색</SelectItem>
|
||
<SelectItem value="transparent">투명</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
{/* 테두리 스타일 */}
|
||
<div className="space-y-2">
|
||
<Label className="text-xs">테두리 스타일</Label>
|
||
<Select
|
||
value={selectedComponent.componentConfig?.borderStyle || "solid"}
|
||
onValueChange={(value) => {
|
||
handleUpdateProperty(selectedComponent.id, "componentConfig.borderStyle", value);
|
||
}}
|
||
>
|
||
<SelectTrigger className="h-9 text-xs">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="solid">실선</SelectItem>
|
||
<SelectItem value="dashed">점선</SelectItem>
|
||
<SelectItem value="none">없음</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
{/* 접기/펼치기 기능 */}
|
||
<div className="space-y-2 border-t pt-2">
|
||
<div className="flex items-center space-x-2">
|
||
<Checkbox
|
||
id="collapsible"
|
||
checked={selectedComponent.componentConfig?.collapsible || false}
|
||
onCheckedChange={(checked) => {
|
||
handleUpdateProperty(selectedComponent.id, "componentConfig.collapsible", checked);
|
||
}}
|
||
/>
|
||
<Label htmlFor="collapsible" className="cursor-pointer text-xs">
|
||
접기/펼치기 가능
|
||
</Label>
|
||
</div>
|
||
|
||
{selectedComponent.componentConfig?.collapsible && (
|
||
<div className="ml-6 flex items-center space-x-2">
|
||
<Checkbox
|
||
id="defaultOpen"
|
||
checked={selectedComponent.componentConfig?.defaultOpen !== false}
|
||
onCheckedChange={(checked) => {
|
||
handleUpdateProperty(selectedComponent.id, "componentConfig.defaultOpen", checked);
|
||
}}
|
||
/>
|
||
<Label htmlFor="defaultOpen" className="cursor-pointer text-xs">
|
||
기본으로 펼치기
|
||
</Label>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
case "section-paper":
|
||
return (
|
||
<div className="space-y-4 p-4">
|
||
<div className="space-y-2">
|
||
<h3 className="text-sm font-semibold">Section Paper 설정</h3>
|
||
<p className="text-muted-foreground text-xs">배경색 기반의 미니멀한 그룹화 컨테이너</p>
|
||
</div>
|
||
|
||
{/* 배경색 */}
|
||
<div className="space-y-2">
|
||
<Label className="text-xs">배경색</Label>
|
||
<Select
|
||
value={selectedComponent.componentConfig?.backgroundColor || "default"}
|
||
onValueChange={(value) => {
|
||
handleUpdateProperty(selectedComponent.id, "componentConfig.backgroundColor", value);
|
||
}}
|
||
>
|
||
<SelectTrigger className="h-9 text-xs">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="default">기본 (연한 회색)</SelectItem>
|
||
<SelectItem value="muted">회색</SelectItem>
|
||
<SelectItem value="accent">강조 (연한 파랑)</SelectItem>
|
||
<SelectItem value="primary">브랜드 컬러</SelectItem>
|
||
<SelectItem value="custom">커스텀</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
{/* 커스텀 색상 */}
|
||
{selectedComponent.componentConfig?.backgroundColor === "custom" && (
|
||
<div className="space-y-2">
|
||
<Label className="text-xs">커스텀 색상</Label>
|
||
<ColorPickerWithTransparent
|
||
value={selectedComponent.componentConfig?.customColor}
|
||
onChange={(value) => {
|
||
handleUpdateProperty(selectedComponent.id, "componentConfig.customColor", value);
|
||
}}
|
||
defaultColor="#f0f0f0"
|
||
placeholder="#f0f0f0"
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* 패딩 */}
|
||
<div className="space-y-2">
|
||
<Label className="text-xs">내부 여백</Label>
|
||
<Select
|
||
value={selectedComponent.componentConfig?.padding || "md"}
|
||
onValueChange={(value) => {
|
||
handleUpdateProperty(selectedComponent.id, "componentConfig.padding", value);
|
||
}}
|
||
>
|
||
<SelectTrigger className="h-9 text-xs">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="none">없음</SelectItem>
|
||
<SelectItem value="sm">작게 (12px)</SelectItem>
|
||
<SelectItem value="md">중간 (16px)</SelectItem>
|
||
<SelectItem value="lg">크게 (24px)</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
{/* 둥근 모서리 */}
|
||
<div className="space-y-2">
|
||
<Label className="text-xs">둥근 모서리</Label>
|
||
<Select
|
||
value={selectedComponent.componentConfig?.roundedCorners || "md"}
|
||
onValueChange={(value) => {
|
||
handleUpdateProperty(selectedComponent.id, "componentConfig.roundedCorners", value);
|
||
}}
|
||
>
|
||
<SelectTrigger className="h-9 text-xs">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="none">없음</SelectItem>
|
||
<SelectItem value="sm">작게 (2px)</SelectItem>
|
||
<SelectItem value="md">중간 (6px)</SelectItem>
|
||
<SelectItem value="lg">크게 (8px)</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
{/* 그림자 */}
|
||
<div className="space-y-2">
|
||
<Label className="text-xs">그림자</Label>
|
||
<Select
|
||
value={selectedComponent.componentConfig?.shadow || "none"}
|
||
onValueChange={(value) => {
|
||
handleUpdateProperty(selectedComponent.id, "componentConfig.shadow", value);
|
||
}}
|
||
>
|
||
<SelectTrigger className="h-9 text-xs">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="none">없음</SelectItem>
|
||
<SelectItem value="sm">작게</SelectItem>
|
||
<SelectItem value="md">중간</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
{/* 테두리 표시 */}
|
||
<div className="flex items-center space-x-2">
|
||
<Checkbox
|
||
id="showBorder"
|
||
checked={selectedComponent.componentConfig?.showBorder || false}
|
||
onCheckedChange={(checked) => {
|
||
handleUpdateProperty(selectedComponent.id, "componentConfig.showBorder", checked);
|
||
}}
|
||
/>
|
||
<Label htmlFor="showBorder" className="cursor-pointer text-xs">
|
||
미묘한 테두리 표시
|
||
</Label>
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
default:
|
||
// ConfigPanel이 없는 경우 경고 표시
|
||
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">
|
||
컴포넌트 "{componentId || 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",
|
||
"text-input",
|
||
"number-input",
|
||
"date-input",
|
||
"textarea-basic",
|
||
"select-basic",
|
||
"checkbox-basic",
|
||
"radio-basic",
|
||
"entity-search-input",
|
||
"autocomplete-search-input",
|
||
// 새로운 통합 입력 컴포넌트
|
||
"v2-input",
|
||
"v2-select",
|
||
"v2-entity-select",
|
||
"v2-checkbox",
|
||
"v2-radio",
|
||
"v2-textarea",
|
||
"v2-date",
|
||
"v2-datetime",
|
||
"v2-time",
|
||
"v2-file",
|
||
];
|
||
|
||
// 현재 컴포넌트가 입력 필드인지 확인
|
||
const componentType = widget.widgetType || (widget as any).componentId || (widget as any).componentType;
|
||
const isInputField = inputFieldTypes.includes(componentType);
|
||
|
||
return (
|
||
<div className="space-y-2">
|
||
{/* 너비 + 높이 (같은 행) */}
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<div className="space-y-1">
|
||
<Label className="text-xs">너비 (px)</Label>
|
||
<Input
|
||
type="number"
|
||
min={10}
|
||
max={3840}
|
||
step="1"
|
||
value={localWidth}
|
||
onChange={(e) => {
|
||
setLocalWidth(e.target.value);
|
||
}}
|
||
onBlur={(e) => {
|
||
const value = parseInt(e.target.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.value) || 0;
|
||
if (value >= 10) {
|
||
const snappedValue = Math.round(value / 10) * 10;
|
||
handleUpdate("size.width", snappedValue);
|
||
setLocalWidth(String(snappedValue));
|
||
}
|
||
e.currentTarget.blur();
|
||
}
|
||
}}
|
||
placeholder="100"
|
||
className="h-6 w-full px-2 py-0 text-xs"
|
||
/>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label className="text-xs">높이</Label>
|
||
<Input
|
||
type="number"
|
||
value={localHeight}
|
||
onChange={(e) => {
|
||
setLocalHeight(e.target.value);
|
||
}}
|
||
onBlur={(e) => {
|
||
const value = parseInt(e.target.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.value) || 0;
|
||
if (value >= 10) {
|
||
const snappedValue = Math.round(value / 10) * 10;
|
||
handleUpdate("size.height", snappedValue);
|
||
setLocalHeight(String(snappedValue));
|
||
}
|
||
e.currentTarget.blur();
|
||
}
|
||
}}
|
||
step={1}
|
||
placeholder="10"
|
||
className="h-6 w-full px-2 py-0 text-xs"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Title (group/area) */}
|
||
{(selectedComponent.type === "group" || selectedComponent.type === "area") && (
|
||
<div className="space-y-1">
|
||
<Label className="text-xs">제목</Label>
|
||
<Input
|
||
value={group.title || area.title || ""}
|
||
onChange={(e) => handleUpdate("title", e.target.value)}
|
||
placeholder="제목"
|
||
className="h-6 w-full px-2 py-0 text-xs"
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* Description (area만) */}
|
||
{selectedComponent.type === "area" && (
|
||
<div className="space-y-1">
|
||
<Label className="text-xs">설명</Label>
|
||
<Input
|
||
value={area.description || ""}
|
||
onChange={(e) => handleUpdate("description", e.target.value)}
|
||
placeholder="설명"
|
||
className="h-6 w-full px-2 py-0 text-xs"
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* Z-Index */}
|
||
<div className="space-y-1">
|
||
<Label className="text-xs">Z-Index</Label>
|
||
<Input
|
||
type="number"
|
||
step="1"
|
||
value={currentPosition.z || 1}
|
||
onChange={(e) => handleUpdate("position.z", parseInt(e.target.value) || 1)}
|
||
className="h-6 w-full px-2 py-0 text-xs"
|
||
/>
|
||
</div>
|
||
|
||
{/* 라벨 스타일 - 입력 필드에서만 표시 */}
|
||
{isInputField && (
|
||
<Collapsible>
|
||
<CollapsibleTrigger className="flex w-full items-center justify-between rounded-lg bg-slate-50 p-2 text-xs font-medium hover:bg-slate-100">
|
||
라벨 스타일
|
||
<ChevronDown className="h-3.5 w-3.5" />
|
||
</CollapsibleTrigger>
|
||
<CollapsibleContent className="mt-2 space-y-2">
|
||
<div className="space-y-1">
|
||
<Label className="text-xs">라벨 텍스트</Label>
|
||
<Input
|
||
value={
|
||
selectedComponent.style?.labelText !== undefined
|
||
? selectedComponent.style.labelText
|
||
: selectedComponent.label || selectedComponent.componentConfig?.label || ""
|
||
}
|
||
onChange={(e) => {
|
||
handleUpdate("style.labelText", e.target.value);
|
||
handleUpdate("label", e.target.value); // label도 함께 업데이트
|
||
}}
|
||
placeholder="라벨을 입력하세요 (비우면 라벨 없음)"
|
||
className="h-6 w-full px-2 py-0 text-xs"
|
||
/>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<div className="space-y-1">
|
||
<Label className="text-xs">위치</Label>
|
||
<Select
|
||
value={selectedComponent.style?.labelPosition || "top"}
|
||
onValueChange={(value) => handleUpdate("style.labelPosition", value)}
|
||
>
|
||
<SelectTrigger className="h-6 text-xs">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="top">위</SelectItem>
|
||
<SelectItem value="bottom">아래</SelectItem>
|
||
<SelectItem value="left">왼쪽</SelectItem>
|
||
<SelectItem value="right">오른쪽</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label className="text-xs">간격</Label>
|
||
<Input
|
||
value={
|
||
(selectedComponent.style?.labelPosition === "left" || selectedComponent.style?.labelPosition === "right")
|
||
? (selectedComponent.style?.labelGap || "8px")
|
||
: (selectedComponent.style?.labelMarginBottom || "4px")
|
||
}
|
||
onChange={(e) => {
|
||
const pos = selectedComponent.style?.labelPosition;
|
||
if (pos === "left" || pos === "right") {
|
||
handleUpdate("style.labelGap", e.target.value);
|
||
} else {
|
||
handleUpdate("style.labelMarginBottom", e.target.value);
|
||
}
|
||
}}
|
||
className="h-6 w-full px-2 py-0 text-xs"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<div className="space-y-1">
|
||
<Label className="text-xs">크기</Label>
|
||
<Input
|
||
value={selectedComponent.style?.labelFontSize || "12px"}
|
||
onChange={(e) => handleUpdate("style.labelFontSize", e.target.value)}
|
||
className="h-6 w-full px-2 py-0 text-xs"
|
||
/>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label className="text-xs">색상</Label>
|
||
<ColorPickerWithTransparent
|
||
value={selectedComponent.style?.labelColor}
|
||
onChange={(value) => handleUpdate("style.labelColor", value)}
|
||
defaultColor="#212121"
|
||
placeholder="#212121"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<div className="space-y-1">
|
||
<Label className="text-xs">굵기</Label>
|
||
<Select
|
||
value={selectedComponent.style?.labelFontWeight || "500"}
|
||
onValueChange={(value) => handleUpdate("style.labelFontWeight", value)}
|
||
>
|
||
<SelectTrigger className="h-6 text-xs">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="400">보통</SelectItem>
|
||
<SelectItem value="500">중간</SelectItem>
|
||
<SelectItem value="600">굵게</SelectItem>
|
||
<SelectItem value="700">매우 굵게</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="flex items-center space-x-2 pt-5">
|
||
<Checkbox
|
||
checked={selectedComponent.style?.labelDisplay === true || selectedComponent.labelDisplay === true}
|
||
onCheckedChange={(checked) => {
|
||
const boolValue = checked === true;
|
||
// 🔧 "필수"처럼 직접 경로로 업데이트! (style 객체 전체 덮어쓰기 방지)
|
||
handleUpdate("style.labelDisplay", boolValue);
|
||
handleUpdate("labelDisplay", boolValue);
|
||
// labelText도 설정 (처음 켤 때 라벨 텍스트가 없을 수 있음)
|
||
if (boolValue && !selectedComponent.style?.labelText) {
|
||
const labelValue = selectedComponent.label || selectedComponent.componentConfig?.label || "";
|
||
if (labelValue) {
|
||
handleUpdate("style.labelText", labelValue);
|
||
}
|
||
}
|
||
}}
|
||
className="h-4 w-4"
|
||
/>
|
||
<Label className="text-xs">표시</Label>
|
||
</div>
|
||
</div>
|
||
</CollapsibleContent>
|
||
</Collapsible>
|
||
)}
|
||
|
||
{/* 옵션 - 입력 필드에서는 항상 표시, 기타 컴포넌트는 속성이 정의된 경우만 표시 */}
|
||
<div className="grid grid-cols-2 gap-2">
|
||
{(isInputField || widget.required !== undefined) && (() => {
|
||
const colName = widget.columnName || selectedComponent?.columnName;
|
||
const colMeta = colName ? currentTable?.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 (
|
||
<div className="flex items-center space-x-2">
|
||
<Checkbox
|
||
checked={isNotNull || widget.required === true || selectedComponent.componentConfig?.required === true}
|
||
onCheckedChange={(checked) => {
|
||
if (isNotNull) return;
|
||
handleUpdate("required", checked);
|
||
handleUpdate("componentConfig.required", checked);
|
||
}}
|
||
disabled={!!isNotNull}
|
||
className="h-4 w-4"
|
||
/>
|
||
<Label className="text-xs">
|
||
필수
|
||
{isNotNull && <span className="text-muted-foreground ml-1">(NOT NULL)</span>}
|
||
</Label>
|
||
</div>
|
||
);
|
||
})()}
|
||
{(isInputField || widget.readonly !== undefined) && (
|
||
<div className="flex items-center space-x-2">
|
||
<Checkbox
|
||
checked={widget.readonly === true || selectedComponent.componentConfig?.readonly === true}
|
||
onCheckedChange={(checked) => {
|
||
handleUpdate("readonly", checked);
|
||
handleUpdate("componentConfig.readonly", checked);
|
||
}}
|
||
className="h-4 w-4"
|
||
/>
|
||
<Label className="text-xs">읽기전용</Label>
|
||
</div>
|
||
)}
|
||
{/* 숨김 옵션 - 모든 컴포넌트에서 표시 */}
|
||
<div className="flex items-center space-x-2">
|
||
<Checkbox
|
||
checked={selectedComponent.hidden === true || selectedComponent.componentConfig?.hidden === true}
|
||
onCheckedChange={(checked) => {
|
||
handleUpdate("hidden", checked);
|
||
handleUpdate("componentConfig.hidden", checked);
|
||
}}
|
||
className="h-4 w-4"
|
||
/>
|
||
<Label className="text-xs">숨김</Label>
|
||
</div>
|
||
</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.tableName && selectedComponent.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이 있는지 먼저 확인
|
||
const definition = ComponentRegistry.getComponent(componentId);
|
||
if (definition?.configPanel) {
|
||
// 전용 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.tableName || currentTable?.tableName || currentTableName}
|
||
tableColumns={currentTable?.columns || []}
|
||
tables={tables}
|
||
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
|
||
allComponents={allComponents} // 🆕 연쇄 드롭다운 부모 감지용
|
||
currentComponent={selectedComponent} // 🆕 현재 컴포넌트 정보
|
||
onChange={(newConfig) => {
|
||
console.log("🔧 [V2PropertiesPanel] DynamicConfigPanel onChange:", {
|
||
componentId: selectedComponent.id,
|
||
newConfigKeys: Object.keys(newConfig),
|
||
defaultSort: newConfig.defaultSort,
|
||
newConfig,
|
||
});
|
||
// 개별 속성별로 업데이트하여 다른 속성과의 충돌 방지
|
||
Object.entries(newConfig).forEach(([key, value]) => {
|
||
console.log(` -> handleUpdate: componentConfig.${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") {
|
||
console.log("✅ [renderDetailTab] Widget 타입");
|
||
const widget = selectedComponent as WidgetComponent;
|
||
console.log("🔍 [renderDetailTab] widget.widgetType:", widget.widgetType);
|
||
|
||
// 새로운 컴포넌트 시스템 (widgetType이 button, card 등) - 먼저 체크
|
||
if (
|
||
widget.widgetType &&
|
||
["button", "card", "dashboard", "stats-card", "progress-bar", "chart", "alert", "badge"].includes(
|
||
widget.widgetType,
|
||
)
|
||
) {
|
||
console.log("✅ [renderDetailTab] DynamicComponent 반환 (widgetType)");
|
||
return (
|
||
<DynamicComponentConfigPanel
|
||
componentId={widget.widgetType}
|
||
config={widget.componentConfig || {}}
|
||
screenTableName={widget.tableName || currentTable?.tableName || currentTableName}
|
||
tableColumns={currentTable?.columns || []}
|
||
tables={tables}
|
||
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
|
||
allComponents={allComponents} // 🆕 연쇄 드롭다운 부모 감지용
|
||
currentComponent={selectedComponent} // 🆕 현재 컴포넌트 정보
|
||
onChange={(newConfig) => {
|
||
console.log("🔄 DynamicComponentConfigPanel onChange (widget):", newConfig);
|
||
// 전체 componentConfig를 업데이트
|
||
handleUpdate("componentConfig", newConfig);
|
||
}}
|
||
/>
|
||
);
|
||
}
|
||
|
||
// 일반 위젯 (webType 기반)
|
||
console.log("✅ [renderDetailTab] 일반 위젯 렌더링 시작");
|
||
return (
|
||
<div className="space-y-4">
|
||
{console.log("🔍 [V2PropertiesPanel] widget.webType:", widget.webType, "widget:", widget)}
|
||
{/* WebType 선택 (있는 경우만) */}
|
||
{widget.webType && (
|
||
<div>
|
||
<Label>입력 타입</Label>
|
||
<Select value={widget.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.web_type_name_kor || wt.web_type}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
)}
|
||
|
||
{/* 🆕 테이블 데이터 자동 입력 (모든 widget 컴포넌트) */}
|
||
<Separator />
|
||
<div className="space-y-3 border-4 border-red-500 bg-yellow-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 bg-white">
|
||
{/* 헤더 - 간소화 */}
|
||
<div className="border-border border-b px-3 py-2">
|
||
{selectedComponent.type === "widget" && (
|
||
<div className="text-muted-foreground truncate text-[10px]">
|
||
{(selectedComponent as WidgetComponent).label || selectedComponent.id}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 통합 컨텐츠 (탭 제거) */}
|
||
<div className="flex-1 overflow-x-auto overflow-y-auto p-2">
|
||
<div className="space-y-4 text-xs">
|
||
{/* 기본 설정 */}
|
||
{renderBasicTab()}
|
||
|
||
{/* 상세 설정 */}
|
||
<Separator className="my-2" />
|
||
{renderDetailTab()}
|
||
|
||
{/* 조건부 표시 설정 */}
|
||
{selectedComponent && (
|
||
<>
|
||
<Separator className="my-2" />
|
||
<div className="space-y-2">
|
||
<div className="flex items-center gap-1.5">
|
||
<Zap className="text-primary h-3 w-3" />
|
||
<h4 className="text-xs font-semibold">조건부 표시</h4>
|
||
</div>
|
||
<div className="rounded-md border border-gray-200 p-2">
|
||
<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}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* 스타일 설정 */}
|
||
{selectedComponent && (
|
||
<>
|
||
<Separator className="my-2" />
|
||
<div className="space-y-2">
|
||
<div className="flex items-center gap-1.5">
|
||
<Palette className="text-primary h-3 w-3" />
|
||
<h4 className="text-xs font-semibold">컴포넌트 스타일</h4>
|
||
</div>
|
||
<StyleEditor
|
||
style={selectedComponent.style || {}}
|
||
onStyleChange={(style) => {
|
||
if (onStyleChange) {
|
||
onStyleChange(style);
|
||
} else {
|
||
handleUpdate("style", style);
|
||
}
|
||
}}
|
||
/>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default V2PropertiesPanel;
|