Files
vexplor/frontend/components/screen/panels/V2PropertiesPanel.tsx
DDD1542 c3a43179e3 refactor: update color schemes and improve component styling
- Changed color schemes for various screen types and roles to align with the new design guidelines, enhancing visual consistency across the application.
- Updated background colors for components based on their types, such as changing 'bg-slate-400' to 'bg-muted-foreground' and adjusting other color mappings for better clarity.
- Improved the styling of the ScreenNode and V2PropertiesPanel components to ensure a more cohesive user experience.
- Enhanced the DynamicComponentRenderer to support dynamic loading of column metadata with cache invalidation for better performance.

These changes aim to refine the UI and improve the overall aesthetic of the application, ensuring a more modern and user-friendly interface.
2026-03-15 15:15:44 +09:00

1322 lines
55 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,
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 { ColorPickerWithTransparent } from "../common/ColorPickerWithTransparent";
// ComponentRegistry import (동적 ConfigPanel 가져오기용)
import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";
import { columnMetaCache } from "@/lib/registry/DynamicComponentRenderer";
import { DynamicComponentConfigPanel, hasComponentConfigPanel } 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;
// 🆕 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/V2FieldConfigPanel").V2FieldConfigPanel,
"v2-select": require("@/components/v2/config-panels/V2FieldConfigPanel").V2FieldConfigPanel,
"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 });
};
// 현재 화면의 테이블명 가져오기
const currentTableName = tables?.[0]?.tableName;
// DB input_type 가져오기 (columnMetaCache에서 최신값 조회)
const colName = selectedComponent.columnName || currentConfig.fieldKey || currentConfig.columnName;
const tblName = selectedComponent.tableName || currentTable?.tableName || currentTableName;
const dbMeta = colName && tblName && !colName.includes(".") ? columnMetaCache[tblName]?.[colName] : undefined;
const dbInputType = dbMeta ? (() => { const raw = dbMeta.input_type || dbMeta.inputType; return raw === "direct" || raw === "auto" ? undefined : raw; })() : undefined;
const inputType = dbInputType || currentConfig.inputType || currentConfig.webType || (selectedComponent as any).inputType;
// 컴포넌트별 추가 props
const extraProps: Record<string, any> = {};
const resolvedTableName = selectedComponent.tableName || currentTable?.tableName || currentTableName;
const resolvedColumnName = selectedComponent.columnName || currentConfig.fieldKey || currentConfig.columnName;
if (componentId === "v2-input" || componentId === "v2-select") {
extraProps.inputType = inputType;
extraProps.tableName = resolvedTableName;
extraProps.columnName = resolvedColumnName;
extraProps.screenTableName = resolvedTableName;
}
if (componentId === "v2-input") {
extraProps.allComponents = allComponents;
}
if (componentId === "v2-list") {
extraProps.currentTableName = currentTableName;
}
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 definition = ComponentRegistry.getComponent(componentId);
if (definition?.configPanel) {
const ConfigPanelComponent = definition.configPanel;
const currentConfig = selectedComponent.componentConfig || {};
// 🔧 ConfigPanelWrapper를 인라인 함수 대신 직접 JSX 반환 (리마운트 방지)
const config = currentConfig || definition.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={tables}
allTables={allTables}
screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName}
tableName={selectedComponent.tableName || currentTable?.tableName || currentTableName}
columnName={
(selectedComponent as any).columnName || currentConfig?.columnName || currentConfig?.fieldName
}
inputType={(selectedComponent as any).inputType || currentConfig?.inputType}
componentType={componentType}
tableColumns={currentTable?.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.tableName || currentTable?.tableName || currentTableName}
tableColumns={currentTable?.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">
&quot;{fallbackId || componentType}&quot; .
</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-1">
{/* DIMENSIONS 섹션 */}
<div className="border-border/50 mb-3 border-b pb-3">
<h4 className="text-muted-foreground py-2 text-[10px] font-semibold tracking-wider uppercase">DIMENSIONS</h4>
<div className="flex gap-2">
<div className="flex-1">
<Label className="text-muted-foreground text-[10px]"></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-7 w-full text-xs"
/>
</div>
<div className="flex-1">
<Label className="text-muted-foreground text-[10px]"></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-7 w-full text-xs"
/>
</div>
<div className="flex-1">
<Label className="text-muted-foreground text-[10px]">Z-Index</Label>
<Input
type="number"
step="1"
value={currentPosition.z || 1}
onChange={(e) => handleUpdate("position.z", parseInt(e.target.value) || 1)}
className="h-7 w-full text-xs"
/>
</div>
</div>
</div>
{/* Title (group/area) */}
{(selectedComponent.type === "group" || selectedComponent.type === "area") && (
<div className="border-border/50 mb-3 border-b pb-3">
<h4 className="text-muted-foreground py-2 text-[10px] font-semibold tracking-wider uppercase">CONTENT</h4>
<div className="flex items-center justify-between py-1.5">
<span className="text-muted-foreground text-xs"></span>
<div className="w-[160px]">
<Input
value={group.title || area.title || ""}
onChange={(e) => handleUpdate("title", e.target.value)}
placeholder="제목"
className="h-7 text-xs"
/>
</div>
</div>
{selectedComponent.type === "area" && (
<div className="flex items-center justify-between py-1.5">
<span className="text-muted-foreground text-xs"></span>
<div className="w-[160px]">
<Input
value={area.description || ""}
onChange={(e) => handleUpdate("description", e.target.value)}
placeholder="설명"
className="h-7 text-xs"
/>
</div>
</div>
)}
</div>
)}
{/* OPTIONS 섹션 */}
<div className="border-border/50 mb-3 border-b pb-3">
<h4 className="text-muted-foreground py-2 text-[10px] font-semibold tracking-wider uppercase">OPTIONS</h4>
{(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 justify-between py-1.5">
<span className="text-muted-foreground text-xs">
{isNotNull && <span className="text-muted-foreground/60 ml-1">(NOT NULL)</span>}
</span>
<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"
/>
</div>
);
})()}
{(isInputField || widget.readonly !== undefined) && (
<div className="flex items-center justify-between py-1.5">
<span className="text-muted-foreground text-xs"></span>
<Checkbox
checked={widget.readonly === true || selectedComponent.componentConfig?.readonly === true}
onCheckedChange={(checked) => {
handleUpdate("readonly", checked);
handleUpdate("componentConfig.readonly", checked);
}}
className="h-4 w-4"
/>
</div>
)}
<div className="flex items-center justify-between py-1.5">
<span className="text-muted-foreground text-xs"></span>
<Checkbox
checked={selectedComponent.hidden === true || selectedComponent.componentConfig?.hidden === true}
onCheckedChange={(checked) => {
handleUpdate("hidden", checked);
handleUpdate("componentConfig.hidden", checked);
}}
className="h-4 w-4"
/>
</div>
</div>
{/* LABEL 섹션 - 입력 필드에서만 표시 */}
{isInputField && (
<Collapsible>
<CollapsibleTrigger className="flex w-full items-center justify-between py-0.5 text-left">
<span className="text-muted-foreground text-[10px] font-semibold tracking-wider uppercase">LABEL</span>
<ChevronDown className="text-muted-foreground/50 h-3 w-3 shrink-0" />
</CollapsibleTrigger>
<CollapsibleContent className="mt-1.5 space-y-1">
{/* 라벨 텍스트 */}
<div className="flex items-center justify-between py-1.5">
<span className="text-muted-foreground text-xs"></span>
<div className="w-[160px]">
<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);
}}
placeholder="라벨"
className="h-7 text-xs"
/>
</div>
</div>
{/* 위치 + 간격 */}
<div className="flex gap-2">
<div className="flex-1">
<Label className="text-muted-foreground text-[10px]"></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>
</div>
<div className="flex-1">
<Label className="text-muted-foreground text-[10px]"></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-7 text-xs"
/>
</div>
</div>
{/* 크기 + 색상 */}
<div className="flex gap-2">
<div className="flex-1">
<Label className="text-muted-foreground text-[10px]"></Label>
<Input
value={selectedComponent.style?.labelFontSize || "12px"}
onChange={(e) => handleUpdate("style.labelFontSize", e.target.value)}
className="h-7 text-xs"
/>
</div>
<div className="flex-1">
<Label className="text-muted-foreground text-[10px]"></Label>
<ColorPickerWithTransparent
value={selectedComponent.style?.labelColor}
onChange={(value) => handleUpdate("style.labelColor", value)}
defaultColor="#212121"
placeholder="#212121"
/>
</div>
</div>
{/* 굵기 */}
<div className="flex items-center justify-between py-1.5">
<span className="text-muted-foreground text-xs"></span>
<div className="w-[160px]">
<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>
</div>
</div>
{/* 표시 */}
<div className="flex items-center justify-between py-1.5">
<span className="text-muted-foreground text-xs"></span>
<Checkbox
checked={selectedComponent.style?.labelDisplay === true || selectedComponent.labelDisplay === true}
onCheckedChange={(checked) => {
const boolValue = checked === true;
handleUpdate("style.labelDisplay", boolValue);
handleUpdate("labelDisplay", boolValue);
if (boolValue && !selectedComponent.style?.labelText) {
const labelValue = selectedComponent.label || selectedComponent.componentConfig?.label || "";
if (labelValue) {
handleUpdate("style.labelText", labelValue);
}
}
}}
className="h-4 w-4"
/>
</div>
</CollapsibleContent>
</Collapsible>
)}
</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) => {
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.tableName || currentTable?.tableName || currentTableName}
tableColumns={currentTable?.columns || []}
tables={tables}
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
allComponents={allComponents} // 🆕 연쇄 드롭다운 부모 감지용
currentComponent={selectedComponent} // 🆕 현재 컴포넌트 정보
onChange={(newConfig) => {
handleUpdate("componentConfig", newConfig);
}}
/>
);
}
// 일반 위젯 (webType 기반)
return (
<div className="space-y-4">
{/* 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="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 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="border-border rounded-md border 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;