feat: Docker 및 컴포넌트 최적화

- Docker Compose 설정에서 Node.js 메모리 제한을 8192MB로 증가시키고, Next.js telemetry를 비활성화하여 성능을 개선하였습니다.
- Next.js 구성에서 메모리 사용량 최적화를 위한 webpackMemoryOptimizations를 활성화하였습니다.
- ScreenModal 컴포넌트에서 overflow 속성을 조정하여 라벨이 잘리지 않도록 개선하였습니다.
- InteractiveScreenViewerDynamic 컴포넌트에서 라벨 표시 여부를 확인하는 로직을 추가하여 사용자 경험을 향상시켰습니다.
- RealtimePreviewDynamic 컴포넌트에서 라벨 표시 및 디버깅 로그를 추가하여 렌더링 과정을 추적할 수 있도록 하였습니다.
- ImprovedButtonControlConfigPanel에서 controlMode 설정을 추가하여 플로우 제어 기능을 개선하였습니다.
- V2PropertiesPanel에서 라벨 텍스트 및 표시 상태 업데이트 로직을 개선하여 일관성을 높였습니다.
- DynamicComponentRenderer에서 라벨 표시 로직을 개선하여 사용자 정의 스타일을 보다 효과적으로 적용할 수 있도록 하였습니다.
- layoutV2Converter에서 webTypeConfig를 병합하여 버튼 제어 기능과 플로우 가시성을 보존하였습니다.
This commit is contained in:
DDD1542 2026-02-04 18:01:20 +09:00
parent 593209e26e
commit 32139beebc
15 changed files with 295 additions and 130 deletions

View File

@ -9,7 +9,8 @@ services:
- "9771:3000" - "9771:3000"
environment: environment:
- NEXT_PUBLIC_API_URL=http://localhost:8080/api - NEXT_PUBLIC_API_URL=http://localhost:8080/api
- NODE_OPTIONS=--max-old-space-size=4096 - NODE_OPTIONS=--max-old-space-size=8192
- NEXT_TELEMETRY_DISABLED=1
volumes: volumes:
- ../../frontend:/app - ../../frontend:/app
- /app/node_modules - /app/node_modules

View File

@ -603,7 +603,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
</DialogHeader> </DialogHeader>
<div <div
className="flex-1 overflow-hidden flex items-center justify-center" className="flex-1 overflow-auto flex items-start justify-center pt-6"
> >
{loading ? ( {loading ? (
<div className="flex h-full items-center justify-center"> <div className="flex h-full items-center justify-center">
@ -620,6 +620,8 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
style={{ style={{
width: `${screenDimensions?.width || 800}px`, width: `${screenDimensions?.width || 800}px`,
height: `${screenDimensions?.height || 600}px`, height: `${screenDimensions?.height || 600}px`,
// 🆕 라벨이 위로 튀어나갈 수 있도록 overflow visible 설정
overflow: "visible",
}} }}
> >
{(() => { {(() => {

View File

@ -1062,22 +1062,35 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
// TableSearchWidget의 경우 높이를 자동으로 설정 // TableSearchWidget의 경우 높이를 자동으로 설정
const isTableSearchWidget = (component as any).componentId === "table-search-widget"; const isTableSearchWidget = (component as any).componentId === "table-search-widget";
// 🆕 라벨 표시 여부 확인 (V2 입력 컴포넌트)
// labelDisplay가 false가 아니고, labelText 또는 label이 있으면 라벨 표시
const isV2InputComponent = type === "v2-input" || type === "v2-select" || type === "v2-date";
const hasVisibleLabel = isV2InputComponent &&
style?.labelDisplay !== false &&
(style?.labelText || (component as any).label);
// 라벨이 있는 경우 상단 여백 계산 (라벨 폰트크기 + 여백)
const labelFontSize = style?.labelFontSize ? parseInt(String(style.labelFontSize)) : 14;
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
const labelOffset = hasVisibleLabel ? (labelFontSize + labelMarginBottom + 2) : 0;
const componentStyle = { const componentStyle = {
position: "absolute" as const, position: "absolute" as const,
left: position?.x || 0, left: position?.x || 0,
top: position?.y || 0, top: position?.y || 0, // 원래 위치 유지 (음수로 가면 overflow-hidden에 잘림)
zIndex: position?.z || 1, zIndex: position?.z || 1,
...styleWithoutSize, // width/height 제외한 스타일만 먼저 적용 ...styleWithoutSize, // width/height 제외한 스타일만 먼저 적용
width: size?.width || 200, // size의 픽셀 값이 최종 우선순위 width: size?.width || 200, // size의 픽셀 값이 최종 우선순위
height: isTableSearchWidget ? "auto" : size?.height || 10, height: isTableSearchWidget ? "auto" : size?.height || 10,
minHeight: isTableSearchWidget ? "48px" : undefined, minHeight: isTableSearchWidget ? "48px" : undefined,
// 🆕 라벨이 있으면 overflow visible로 설정하여 라벨이 잘리지 않게 함
overflow: labelOffset > 0 ? "visible" : undefined,
}; };
return ( return (
<> <>
<div className="absolute" style={componentStyle}> <div className="absolute" style={componentStyle}>
{/* 라벨 숨김 - 모달에서는 라벨을 표시하지 않음 */} {/* 위젯 렌더링 (라벨은 V2Input 내부에서 absolute로 표시됨) */}
{/* 위젯 렌더링 */}
{renderInteractiveWidget(component)} {renderInteractiveWidget(component)}
</div> </div>

View File

@ -119,6 +119,9 @@ const WidgetRenderer: React.FC<{
tableDisplayData?: any[]; tableDisplayData?: any[];
[key: string]: any; [key: string]: any;
}> = ({ component, isDesignMode = false, sortBy, sortOrder, tableDisplayData, ...restProps }) => { }> = ({ component, isDesignMode = false, sortBy, sortOrder, tableDisplayData, ...restProps }) => {
// 🔧 무조건 로그 (렌더링 확인용)
console.log("📦 WidgetRenderer 렌더링:", component.id, "labelDisplay:", component.style?.labelDisplay);
// 위젯 컴포넌트가 아닌 경우 빈 div 반환 // 위젯 컴포넌트가 아닌 경우 빈 div 반환
if (!isWidgetComponent(component)) { if (!isWidgetComponent(component)) {
return <div className="text-xs text-gray-500"> </div>; return <div className="text-xs text-gray-500"> </div>;
@ -127,9 +130,6 @@ const WidgetRenderer: React.FC<{
const widget = component; const widget = component;
const { widgetType, label, placeholder, required, readonly, columnName, style } = widget; const { widgetType, label, placeholder, required, readonly, columnName, style } = widget;
// 디버깅: 실제 widgetType 값 확인
// console.log("RealtimePreviewDynamic - widgetType:", widgetType, "columnName:", columnName);
// 사용자가 테두리를 설정했는지 확인 // 사용자가 테두리를 설정했는지 확인
const hasCustomBorder = style && (style.borderWidth || style.borderStyle || style.borderColor || style.border); const hasCustomBorder = style && (style.borderWidth || style.borderStyle || style.borderColor || style.border);
@ -246,8 +246,17 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
tableDisplayData, // 🆕 화면 표시 데이터 tableDisplayData, // 🆕 화면 표시 데이터
...restProps ...restProps
}) => { }) => {
// 🔧 무조건 로그 - 파일 반영 테스트용 (2024-TEST)
console.log("🔷🔷🔷 RealtimePreview 2024:", component.id);
const { user } = useAuth(); const { user } = useAuth();
const { type, id, position, size, style = {} } = component; const { type, id, position, size, style = {} } = component;
// 🔧 v2 컴포넌트 렌더링 추적
if (id?.includes("v2-")) {
console.log("🔷 RealtimePreview 렌더:", id, "type:", type, "labelDisplay:", style?.labelDisplay);
}
const [fileUpdateTrigger, setFileUpdateTrigger] = useState(0); const [fileUpdateTrigger, setFileUpdateTrigger] = useState(0);
const [actualHeight, setActualHeight] = useState<number | null>(null); const [actualHeight, setActualHeight] = useState<number | null>(null);
const contentRef = React.useRef<HTMLDivElement>(null); const contentRef = React.useRef<HTMLDivElement>(null);
@ -741,6 +750,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
{/* 컴포넌트 타입 - 레지스트리 기반 렌더링 (Section Paper, Section Card 등) */} {/* 컴포넌트 타입 - 레지스트리 기반 렌더링 (Section Paper, Section Card 등) */}
{type === "component" && {type === "component" &&
(() => { (() => {
console.log("📦 DynamicComponentRenderer 렌더링:", component.id, "labelDisplay:", component.style?.labelDisplay);
const { DynamicComponentRenderer } = require("@/lib/registry/DynamicComponentRenderer"); const { DynamicComponentRenderer } = require("@/lib/registry/DynamicComponentRenderer");
return ( return (
<DynamicComponentRenderer <DynamicComponentRenderer

View File

@ -644,9 +644,9 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
/> />
</div> </div>
{/* 선택된 컴포넌트 정보 표시 */} {/* 선택된 컴포넌트 정보 표시 - 🔧 오른쪽으로 이동 (라벨과 겹치지 않도록) */}
{isSelected && ( {isSelected && (
<div className="bg-primary text-primary-foreground absolute -top-7 left-0 rounded-md px-2.5 py-1 text-xs font-medium shadow-sm"> <div className="bg-primary text-primary-foreground absolute -top-7 right-0 rounded-md px-2.5 py-1 text-xs font-medium shadow-sm">
{type === "widget" && ( {type === "widget" && (
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
{getWidgetIcon((component as WidgetComponent).widgetType)} {getWidgetIcon((component as WidgetComponent).widgetType)}
@ -685,7 +685,8 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
); );
}; };
// React.memo로 래핑하여 불필요한 리렌더링 방지 // 🔧 arePropsEqual 제거 - 기본 React.memo 사용 (디버깅용)
// component 객체가 새로 생성되면 자동으로 리렌더링됨
export const RealtimePreviewDynamic = React.memo(RealtimePreviewDynamicComponent); export const RealtimePreviewDynamic = React.memo(RealtimePreviewDynamicComponent);
// displayName 설정 (디버깅용) // displayName 설정 (디버깅용)

View File

@ -472,14 +472,20 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
// 이미 배치된 컬럼 목록 계산 // 이미 배치된 컬럼 목록 계산
const placedColumns = useMemo(() => { const placedColumns = useMemo(() => {
const placed = new Set<string>(); const placed = new Set<string>();
// 🔧 화면의 메인 테이블명을 fallback으로 사용
const screenTableName = selectedScreen?.tableName;
const collectColumns = (components: ComponentData[]) => { const collectColumns = (components: ComponentData[]) => {
components.forEach((comp) => { components.forEach((comp) => {
const anyComp = comp as any; const anyComp = comp as any;
// widget 타입 또는 component 타입 (새로운 시스템)에서 tableName과 columnName 확인 // 🔧 tableName과 columnName을 여러 위치에서 찾기 (최상위, componentConfig, 또는 화면 테이블명)
if ((comp.type === "widget" || comp.type === "component") && anyComp.tableName && anyComp.columnName) { const tableName = anyComp.tableName || anyComp.componentConfig?.tableName || screenTableName;
const key = `${anyComp.tableName}.${anyComp.columnName}`; const columnName = anyComp.columnName || anyComp.componentConfig?.columnName;
// widget 타입 또는 component 타입에서 columnName 확인 (tableName은 화면 테이블명으로 fallback)
if ((comp.type === "widget" || comp.type === "component") && tableName && columnName) {
const key = `${tableName}.${columnName}`;
placed.add(key); placed.add(key);
} }
@ -492,7 +498,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
collectColumns(layout.components); collectColumns(layout.components);
return placed; return placed;
}, [layout.components]); }, [layout.components, selectedScreen?.tableName]);
// 히스토리에 저장 // 히스토리에 저장
const saveToHistory = useCallback( const saveToHistory = useCallback(
@ -770,6 +776,22 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
const finalKey = pathParts[pathParts.length - 1]; const finalKey = pathParts[pathParts.length - 1];
current[finalKey] = value; current[finalKey] = value;
// 🔧 style 관련 업데이트 디버그 로그
if (path.includes("style") || path.includes("labelDisplay")) {
console.log("🎨 style 업데이트 제대로 렌더링된거다 내가바꿈:", {
componentId: comp.id,
path,
value,
updatedStyle: newComp.style,
pathIncludesLabelDisplay: path.includes("labelDisplay"),
});
}
// 🆕 labelDisplay 변경 시 강제 리렌더링 트리거 (조건문 밖으로 이동)
if (path === "style.labelDisplay") {
console.log("⏰⏰⏰ labelDisplay 변경 감지! forceRenderTrigger 실행 예정");
}
// 🆕 size 변경 시 style도 함께 업데이트 (파란 테두리와 실제 크기 동기화) // 🆕 size 변경 시 style도 함께 업데이트 (파란 테두리와 실제 크기 동기화)
if (path === "size.width" || path === "size.height" || path === "size") { if (path === "size.width" || path === "size.height" || path === "size") {
// 🔧 style 객체를 새로 복사하여 불변성 유지 // 🔧 style 객체를 새로 복사하여 불변성 유지
@ -1787,97 +1809,21 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
const buttonComponents = layoutWithResolution.components.filter( const buttonComponents = layoutWithResolution.components.filter(
(c: any) => c.componentType?.startsWith("button") || c.type === "button" || c.type === "button-primary", (c: any) => c.componentType?.startsWith("button") || c.type === "button" || c.type === "button-primary",
); );
console.log("💾 저장 시작:", { // 💾 저장 로그 (디버그 완료 - 간소화)
screenId: selectedScreen.screenId, // console.log("💾 저장 시작:", { screenId: selectedScreen.screenId, componentsCount: layoutWithResolution.components.length });
componentsCount: layoutWithResolution.components.length, // 분할 패널 디버그 로그 (주석 처리)
gridSettings: layoutWithResolution.gridSettings,
screenResolution: layoutWithResolution.screenResolution,
buttonComponents: buttonComponents.map((c: any) => ({
id: c.id,
type: c.type,
componentType: c.componentType,
text: c.componentConfig?.text,
actionType: c.componentConfig?.action?.type,
fullAction: c.componentConfig?.action,
})),
});
// 🔍 디버그: 분할 패널 내부의 탭 및 컴포넌트 설정 확인
const splitPanels = layoutWithResolution.components.filter(
(c: any) => c.componentType === "v2-split-panel-layout" || c.componentType === "split-panel-layout"
);
splitPanels.forEach((sp: any) => {
console.log("🔍 [저장] 분할 패널 설정:", {
id: sp.id,
leftPanel: sp.componentConfig?.leftPanel,
rightPanel: sp.componentConfig?.rightPanel,
});
// 🆕 분할 패널 내 모든 컴포넌트의 componentConfig 로그
const rightComponents = sp.componentConfig?.rightPanel?.components || [];
console.log("🔍 [저장] 오른쪽 패널 컴포넌트들:", rightComponents.map((c: any) => ({
id: c.id,
componentType: c.componentType,
hasComponentConfig: !!c.componentConfig,
componentConfig: JSON.parse(JSON.stringify(c.componentConfig || {})),
})));
// 왼쪽 패널의 탭 컴포넌트 확인
const leftTabs = sp.componentConfig?.leftPanel?.components?.filter(
(c: any) => c.componentType === "v2-tabs-widget"
);
leftTabs?.forEach((tabWidget: any) => {
console.log("🔍 [저장] 왼쪽 패널 탭 위젯 전체 componentConfig:", {
tabWidgetId: tabWidget.id,
fullComponentConfig: JSON.parse(JSON.stringify(tabWidget.componentConfig || {})),
});
console.log("🔍 [저장] 왼쪽 패널 탭 내부 컴포넌트:", {
tabId: tabWidget.id,
tabs: tabWidget.componentConfig?.tabs?.map((t: any) => ({
id: t.id,
label: t.label,
componentsCount: t.components?.length || 0,
components: t.components,
})),
});
});
// 오른쪽 패널의 탭 컴포넌트 확인
const rightTabs = sp.componentConfig?.rightPanel?.components?.filter(
(c: any) => c.componentType === "v2-tabs-widget"
);
rightTabs?.forEach((tabWidget: any) => {
console.log("🔍 [저장] 오른쪽 패널 탭 위젯 전체 componentConfig:", {
tabWidgetId: tabWidget.id,
fullComponentConfig: JSON.parse(JSON.stringify(tabWidget.componentConfig || {})),
});
console.log("🔍 [저장] 오른쪽 패널 탭 내부 컴포넌트:", {
tabId: tabWidget.id,
tabs: tabWidget.componentConfig?.tabs?.map((t: any) => ({
id: t.id,
label: t.label,
componentsCount: t.components?.length || 0,
components: t.components,
})),
});
});
});
// V2 API 사용 여부에 따라 분기 // V2 API 사용 여부에 따라 분기
if (USE_V2_API) { if (USE_V2_API) {
// 🔧 V2 레이아웃 저장 (디버그 로그 주석 처리)
const v2Layout = convertLegacyToV2(layoutWithResolution); const v2Layout = convertLegacyToV2(layoutWithResolution);
console.log("📦 V2 변환 결과 (분할 패널 overrides):", v2Layout.components
.filter((c: any) => c.url?.includes("split-panel"))
.map((c: any) => ({
id: c.id,
url: c.url,
overrides: c.overrides,
}))
);
await screenApi.saveLayoutV2(selectedScreen.screenId, v2Layout); await screenApi.saveLayoutV2(selectedScreen.screenId, v2Layout);
console.log("📦 V2 레이아웃 저장:", v2Layout.components.length, "개 컴포넌트"); // console.log("📦 V2 레이아웃 저장:", v2Layout.components.length, "개 컴포넌트");
} else { } else {
await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution); await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution);
} }
console.log("✅ 저장 성공! 메뉴 할당 모달 열기"); // console.log("✅ 저장 성공!");
toast.success("화면이 저장되었습니다."); toast.success("화면이 저장되었습니다.");
// 저장 성공 후 부모에게 화면 정보 업데이트 알림 (테이블명 즉시 반영) // 저장 성공 후 부모에게 화면 정보 업데이트 알림 (테이블명 즉시 반영)
@ -3084,7 +3030,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
}, },
webTypeConfig: getDefaultWebTypeConfig(component.webType), webTypeConfig: getDefaultWebTypeConfig(component.webType),
style: { style: {
labelDisplay: false, // 모든 컴포넌트의 기본 라벨 표시를 false로 설정 labelDisplay: true, // 🆕 라벨 기본 표시 (사용자가 끄고 싶으면 체크 해제)
labelFontSize: "14px", labelFontSize: "14px",
labelColor: "#212121", labelColor: "#212121",
labelFontWeight: "500", labelFontWeight: "500",
@ -3750,7 +3696,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
entityJoinColumn: column.entityJoinColumn, entityJoinColumn: column.entityJoinColumn,
}), }),
style: { style: {
labelDisplay: false, // 라벨 숨김 labelDisplay: true, // 🆕 라벨 기본 표시
labelFontSize: "12px", labelFontSize: "12px",
labelColor: "#212121", labelColor: "#212121",
labelFontWeight: "500", labelFontWeight: "500",
@ -3816,7 +3762,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
entityJoinColumn: column.entityJoinColumn, entityJoinColumn: column.entityJoinColumn,
}), }),
style: { style: {
labelDisplay: false, // 라벨 숨김 labelDisplay: true, // 🆕 라벨 기본 표시
labelFontSize: "14px", labelFontSize: "14px",
labelColor: "#000000", // 순수한 검정 labelColor: "#000000", // 순수한 검정
labelFontWeight: "500", labelFontWeight: "500",
@ -5452,6 +5398,9 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
); );
} }
// 🔧 ScreenDesigner 렌더링 확인 (디버그 완료 - 주석 처리)
// console.log("🏠 ScreenDesigner 렌더!", Date.now());
return ( return (
<ScreenPreviewProvider isPreviewMode={false}> <ScreenPreviewProvider isPreviewMode={false}>
<TableOptionsProvider> <TableOptionsProvider>
@ -6163,6 +6112,9 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
// 그룹에 속하지 않은 일반 컴포넌트들 // 그룹에 속하지 않은 일반 컴포넌트들
const regularComponents = topLevelComponents.filter((c) => !processedButtonIds.has(c.id)); const regularComponents = topLevelComponents.filter((c) => !processedButtonIds.has(c.id));
// 🔧 렌더링 확인 로그 (디버그 완료 - 주석 처리)
// console.log("🔄 ScreenDesigner 렌더링:", { componentsCount: regularComponents.length, forceRenderTrigger, timestamp: Date.now() });
return ( return (
<> <>
{/* 일반 컴포넌트들 */} {/* 일반 컴포넌트들 */}
@ -6228,11 +6180,23 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
const globalFiles = globalFileState[component.id] || []; const globalFiles = globalFileState[component.id] || [];
const componentFiles = (component as any).uploadedFiles || []; const componentFiles = (component as any).uploadedFiles || [];
const fileStateKey = `${globalFiles.length}-${JSON.stringify(globalFiles.map((f: any) => f.objid) || [])}-${componentFiles.length}`; const fileStateKey = `${globalFiles.length}-${JSON.stringify(globalFiles.map((f: any) => f.objid) || [])}-${componentFiles.length}`;
// 🆕 style 변경 시 리렌더링을 위한 key 추가
const styleKey = component.style?.labelDisplay !== undefined ? `label-${component.style.labelDisplay}` : "";
const fullKey = `${component.id}-${fileStateKey}-${styleKey}-${(component as any).lastFileUpdate || 0}-${forceRenderTrigger}`;
// 🔧 v2-input 계열 컴포넌트 key 변경 로그 (디버그 완료 - 주석 처리)
// if (component.id.includes("v2-") || component.widgetType?.includes("v2-")) { console.log("🔑 RealtimePreview key:", { id: component.id, styleKey, labelDisplay: component.style?.labelDisplay, forceRenderTrigger, fullKey }); }
// 🆕 labelDisplay 변경 시 새 객체로 강제 변경 감지
const componentWithLabel = {
...displayComponent,
_labelDisplayKey: component.style?.labelDisplay,
};
return ( return (
<RealtimePreview <RealtimePreview
key={`${component.id}-${fileStateKey}-${(component as any).lastFileUpdate || 0}-${forceRenderTrigger}`} key={fullKey}
component={displayComponent} component={componentWithLabel}
isSelected={ isSelected={
selectedComponent?.id === component.id || selectedComponent?.id === component.id ||
groupState.selectedComponents.includes(component.id) groupState.selectedComponents.includes(component.id)

View File

@ -173,6 +173,8 @@ export const ImprovedButtonControlConfigPanel: React.FC<ImprovedButtonControlCon
onUpdateProperty("webTypeConfig.dataflowConfig", { onUpdateProperty("webTypeConfig.dataflowConfig", {
...dataflowConfig, ...dataflowConfig,
// 🔧 controlMode 설정 (플로우 제어가 있으면 "flow", 없으면 "none")
controlMode: firstValidControl ? "flow" : "none",
// 기존 형식 (하위 호환성) // 기존 형식 (하위 호환성)
selectedDiagramId: firstValidControl?.flowId || null, selectedDiagramId: firstValidControl?.flowId || null,
selectedRelationshipId: null, selectedRelationshipId: null,

View File

@ -822,7 +822,7 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-xs"> </Label> <Label className="text-xs"> </Label>
<Input <Input
value={selectedComponent.style?.labelText !== undefined ? selectedComponent.style.labelText : (selectedComponent.label || "")} value={selectedComponent.style?.labelText !== undefined ? selectedComponent.style.labelText : (selectedComponent.label || selectedComponent.componentConfig?.label || "")}
onChange={(e) => { onChange={(e) => {
handleUpdate("style.labelText", e.target.value); handleUpdate("style.labelText", e.target.value);
handleUpdate("label", e.target.value); // label도 함께 업데이트 handleUpdate("label", e.target.value); // label도 함께 업데이트
@ -861,8 +861,23 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
</div> </div>
<div className="flex items-center space-x-2 pt-5"> <div className="flex items-center space-x-2 pt-5">
<Checkbox <Checkbox
checked={selectedComponent.style?.labelDisplay !== false} checked={selectedComponent.style?.labelDisplay === true || selectedComponent.labelDisplay === true}
onCheckedChange={(checked) => handleUpdate("style.labelDisplay", checked)} 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" className="h-4 w-4"
/> />
<Label className="text-xs"></Label> <Label className="text-xs"></Label>

View File

@ -802,7 +802,9 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
}; };
// 라벨이 표시될 때 입력 필드가 차지할 높이 계산 // 라벨이 표시될 때 입력 필드가 차지할 높이 계산
const showLabel = label && style?.labelDisplay !== false; // 🔧 label prop이 없어도 style.labelText에서 가져올 수 있도록 수정
const actualLabel = label || style?.labelText;
const showLabel = actualLabel && style?.labelDisplay === true;
// size에서 우선 가져오고, 없으면 style에서 가져옴 // size에서 우선 가져오고, 없으면 style에서 가져옴
const componentWidth = size?.width || style?.width; const componentWidth = size?.width || style?.width;
const componentHeight = size?.height || style?.height; const componentHeight = size?.height || style?.height;
@ -836,7 +838,7 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
}} }}
className="text-sm font-medium whitespace-nowrap" className="text-sm font-medium whitespace-nowrap"
> >
{label} {actualLabel}
{required && <span className="ml-0.5 text-orange-500">*</span>} {required && <span className="ml-0.5 text-orange-500">*</span>}
</Label> </Label>
)} )}

View File

@ -513,6 +513,18 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
componentType === "modal-repeater-table" || componentType === "modal-repeater-table" ||
componentType === "v2-input"; componentType === "v2-input";
// 🆕 v2-input 등의 라벨 표시 로직 (labelDisplay가 true일 때만 라벨 표시)
const labelDisplay = component.style?.labelDisplay ?? (component as any).labelDisplay;
const effectiveLabel = labelDisplay === true
? (component.style?.labelText || (component as any).label || component.componentConfig?.label)
: undefined;
// 🔧 순서 중요! finalStyle 먼저, component.style 나중에 (커스텀 속성이 CSS 속성을 덮어써야 함)
const mergedStyle = {
...finalStyle, // CSS 속성 (width, height 등) - 먼저!
...component.style, // 원본 style (labelDisplay, labelText 등) - 나중에! (덮어씀)
};
const rendererProps = { const rendererProps = {
component, component,
isSelected, isSelected,
@ -521,11 +533,14 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
onDragEnd, onDragEnd,
size: component.size || newComponent.defaultSize, size: component.size || newComponent.defaultSize,
position: component.position, position: component.position,
style: finalStyle, // size를 포함한 최종 style
config: component.componentConfig, config: component.componentConfig,
componentConfig: component.componentConfig, componentConfig: component.componentConfig,
// componentConfig의 모든 속성을 props로 spread (tableName, displayField 등) // componentConfig의 모든 속성을 props로 spread (tableName, displayField 등)
...(component.componentConfig || {}), ...(component.componentConfig || {}),
// 🔧 style은 맨 마지막에! (componentConfig.style이 있어도 mergedStyle이 우선)
style: mergedStyle,
// 🆕 라벨 표시 (labelDisplay가 true일 때만)
label: effectiveLabel,
// 🆕 V2 레이아웃에서 overrides에서 복원된 상위 레벨 속성들도 전달 // 🆕 V2 레이아웃에서 overrides에서 복원된 상위 레벨 속성들도 전달
inputType: (component as any).inputType || component.componentConfig?.inputType, inputType: (component as any).inputType || component.componentConfig?.inputType,
columnName: (component as any).columnName || component.componentConfig?.columnName, columnName: (component as any).columnName || component.componentConfig?.columnName,

View File

@ -555,13 +555,10 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
} }
// 스타일 계산 // 스타일 계산
// height: 100%로 부모(RealtimePreviewDynamic의 내부 div)의 높이를 따라감 // 🔧 사용자가 설정한 크기가 있으면 그대로 사용
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
const componentStyle: React.CSSProperties = { const componentStyle: React.CSSProperties = {
...component.style, ...component.style,
...style, ...style,
width: "100%",
height: "100%",
}; };
// 디자인 모드 스타일 (border 속성 분리하여 충돌 방지) // 디자인 모드 스타일 (border 속성 분리하여 충돌 방지)
@ -1289,19 +1286,23 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
componentConfig.disabled || isOperationButtonDisabled || isRowSelectionDisabled || statusLoading; componentConfig.disabled || isOperationButtonDisabled || isRowSelectionDisabled || statusLoading;
// 공통 버튼 스타일 // 공통 버튼 스타일
// 🔧 component.style에서 background/backgroundColor 충돌 방지 // 🔧 component.style에서 background/backgroundColor 충돌 방지 (width/height는 허용)
const userStyle = component.style const userStyle = component.style
? Object.fromEntries( ? Object.fromEntries(
Object.entries(component.style).filter( Object.entries(component.style).filter(
([key]) => !["width", "height", "background", "backgroundColor"].includes(key), ([key]) => !["background", "backgroundColor"].includes(key),
), ),
) )
: {}; : {};
// 🔧 사용자가 설정한 크기 우선 사용, 없으면 100%
const buttonWidth = component.size?.width ? `${component.size.width}px` : (style?.width || "100%");
const buttonHeight = component.size?.height ? `${component.size.height}px` : (style?.height || "100%");
const buttonElementStyle: React.CSSProperties = { const buttonElementStyle: React.CSSProperties = {
width: "100%", width: buttonWidth,
height: "100%", height: buttonHeight,
minHeight: "40px", minHeight: "32px", // 🔧 최소 높이를 32px로 줄임
border: "none", border: "none",
borderRadius: "0.5rem", borderRadius: "0.5rem",
backgroundColor: finalDisabled ? "#e5e7eb" : buttonColor, backgroundColor: finalDisabled ? "#e5e7eb" : buttonColor,

View File

@ -31,9 +31,11 @@ export class V2InputRenderer extends AutoRegisteringComponentRenderer {
}; };
// 라벨: style.labelText 우선, 없으면 component.label 사용 // 라벨: style.labelText 우선, 없으면 component.label 사용
// style.labelDisplay가 false면 라벨 숨김 // 🔧 style.labelDisplay를 먼저 체크 (속성 패널에서 style 객체로 업데이트하므로)
const style = component.style || {}; const style = component.style || {};
const effectiveLabel = style.labelDisplay === false ? undefined : (style.labelText || component.label); const labelDisplay = style.labelDisplay ?? (component as any).labelDisplay;
// labelDisplay: true → 라벨 표시, false → 숨김, undefined → 기존 동작 유지(숨김)
const effectiveLabel = labelDisplay === true ? (style.labelText || component.label) : undefined;
return ( return (
<V2Input <V2Input

View File

@ -43,13 +43,20 @@ export interface ButtonExecutionResult {
} }
interface ControlConfig { interface ControlConfig {
type: "relationship"; type: "relationship" | "flow";
relationshipConfig: { relationshipConfig?: {
relationshipId: string; relationshipId: string;
relationshipName: string; relationshipName: string;
executionTiming: "before" | "after" | "replace"; executionTiming: "before" | "after" | "replace";
contextData?: Record<string, any>; contextData?: Record<string, any>;
}; };
// 🆕 플로우 기반 제어 설정
flowConfig?: {
flowId: number;
flowName: string;
executionTiming: "before" | "after" | "replace";
contextData?: Record<string, any>;
};
} }
interface ExecutionPlan { interface ExecutionPlan {
@ -163,15 +170,22 @@ export class ImprovedButtonActionExecutor {
return plan; return plan;
} }
// enableDataflowControl 체크를 제거하고 dataflowConfig만 있으면 실행 // 🔧 controlMode가 없으면 flowConfig/relationshipConfig 존재 여부로 자동 판단
const effectiveControlMode = dataflowConfig.controlMode
|| (dataflowConfig.flowConfig ? "flow" : null)
|| (dataflowConfig.relationshipConfig ? "relationship" : null)
|| "none";
console.log("📋 실행 계획 생성:", { console.log("📋 실행 계획 생성:", {
controlMode: dataflowConfig.controlMode, controlMode: dataflowConfig.controlMode,
effectiveControlMode,
hasFlowConfig: !!dataflowConfig.flowConfig,
hasRelationshipConfig: !!dataflowConfig.relationshipConfig, hasRelationshipConfig: !!dataflowConfig.relationshipConfig,
enableDataflowControl: buttonConfig.enableDataflowControl, enableDataflowControl: buttonConfig.enableDataflowControl,
}); });
// 관계 기반 제어만 지원 // 관계 기반 제어
if (dataflowConfig.controlMode === "relationship" && dataflowConfig.relationshipConfig) { if (effectiveControlMode === "relationship" && dataflowConfig.relationshipConfig) {
const control: ControlConfig = { const control: ControlConfig = {
type: "relationship", type: "relationship",
relationshipConfig: dataflowConfig.relationshipConfig, relationshipConfig: dataflowConfig.relationshipConfig,
@ -191,11 +205,34 @@ export class ImprovedButtonActionExecutor {
} }
} }
// 🆕 플로우 기반 제어
if (effectiveControlMode === "flow" && dataflowConfig.flowConfig) {
const control: ControlConfig = {
type: "flow",
flowConfig: dataflowConfig.flowConfig,
};
console.log("📋 플로우 제어 설정:", dataflowConfig.flowConfig);
switch (dataflowConfig.flowConfig.executionTiming) {
case "before":
plan.beforeControls.push(control);
break;
case "after":
plan.afterControls.push(control);
break;
case "replace":
plan.afterControls.push(control);
plan.hasReplaceControl = true;
break;
}
}
return plan; return plan;
} }
/** /**
* 🔥 ( ) * 🔥 ( )
*/ */
private static async executeControls( private static async executeControls(
controls: ControlConfig[], controls: ControlConfig[],
@ -206,8 +243,16 @@ export class ImprovedButtonActionExecutor {
for (const control of controls) { for (const control of controls) {
try { try {
// 관계 실행만 지원 let result: ExecutionResult;
const result = await this.executeRelationship(control.relationshipConfig, formData, context);
// 🆕 제어 타입에 따라 분기 처리
if (control.type === "flow" && control.flowConfig) {
result = await this.executeFlow(control.flowConfig, formData, context);
} else if (control.type === "relationship" && control.relationshipConfig) {
result = await this.executeRelationship(control.relationshipConfig, formData, context);
} else {
throw new Error(`지원하지 않는 제어 타입: ${control.type}`);
}
results.push(result); results.push(result);
@ -215,7 +260,7 @@ export class ImprovedButtonActionExecutor {
if (!result.success) { if (!result.success) {
throw new Error(result.message); throw new Error(result.message);
} }
} catch (error) { } catch (error: any) {
console.error(`제어 실행 실패 (${control.type}):`, error); console.error(`제어 실행 실패 (${control.type}):`, error);
results.push({ results.push({
success: false, success: false,
@ -230,6 +275,61 @@ export class ImprovedButtonActionExecutor {
return results; return results;
} }
/**
* 🆕
*/
private static async executeFlow(
config: {
flowId: number;
flowName: string;
executionTiming: "before" | "after" | "replace";
contextData?: Record<string, any>;
},
formData: Record<string, any>,
context: ButtonExecutionContext,
): Promise<ExecutionResult> {
const startTime = Date.now();
try {
console.log(`🔄 플로우 실행 시작: ${config.flowName} (ID: ${config.flowId})`);
// 플로우 실행 API 호출
const response = await apiClient.post(`/api/dataflow/node-flows/${config.flowId}/execute`, {
formData,
contextData: config.contextData || {},
selectedRows: context.selectedRows || [],
flowSelectedData: context.flowSelectedData || [],
screenId: context.screenId,
companyCode: context.companyCode,
userId: context.userId,
});
const executionTime = Date.now() - startTime;
if (response.data?.success) {
console.log(`✅ 플로우 실행 성공: ${config.flowName}`, response.data);
return {
success: true,
message: `플로우 "${config.flowName}" 실행 완료`,
executionTime,
data: response.data,
};
} else {
throw new Error(response.data?.message || "플로우 실행 실패");
}
} catch (error: any) {
const executionTime = Date.now() - startTime;
console.error(`❌ 플로우 실행 실패: ${config.flowName}`, error);
return {
success: false,
message: `플로우 "${config.flowName}" 실행 실패: ${error.message}`,
executionTime,
error: error.message,
};
}
}
/** /**
* 🔥 * 🔥
*/ */

View File

@ -191,6 +191,8 @@ export function convertV2ToLegacy(v2Layout: LayoutV2 | null): LegacyLayoutData |
autoFill: overrides.autoFill, autoFill: overrides.autoFill,
// 🆕 style 설정 복원 (라벨 텍스트, 라벨 스타일 등) // 🆕 style 설정 복원 (라벨 텍스트, 라벨 스타일 등)
style: overrides.style || {}, style: overrides.style || {},
// 🔧 webTypeConfig 복원 (버튼 제어기능, 플로우 가시성 등)
webTypeConfig: overrides.webTypeConfig || {},
// 기존 구조 호환을 위한 추가 필드 // 기존 구조 호환을 위한 추가 필드
parentId: null, parentId: null,
gridColumns: 12, gridColumns: 12,
@ -245,13 +247,47 @@ export function convertLegacyToV2(legacyLayout: LegacyLayoutData): LayoutV2 {
if (comp.autoFill) topLevelProps.autoFill = comp.autoFill; if (comp.autoFill) topLevelProps.autoFill = comp.autoFill;
// 🆕 style 설정 저장 (라벨 텍스트, 라벨 스타일 등) // 🆕 style 설정 저장 (라벨 텍스트, 라벨 스타일 등)
if (comp.style && Object.keys(comp.style).length > 0) topLevelProps.style = comp.style; if (comp.style && Object.keys(comp.style).length > 0) topLevelProps.style = comp.style;
// 🔧 webTypeConfig 저장 (버튼 제어기능, 플로우 가시성 등)
if (comp.webTypeConfig && Object.keys(comp.webTypeConfig).length > 0) {
topLevelProps.webTypeConfig = comp.webTypeConfig;
// 🔍 디버그: webTypeConfig 저장 확인
if (comp.webTypeConfig.dataflowConfig || comp.webTypeConfig.enableDataflowControl) {
console.log("💾 webTypeConfig 저장:", {
componentId: comp.id,
enableDataflowControl: comp.webTypeConfig.enableDataflowControl,
dataflowConfig: comp.webTypeConfig.dataflowConfig,
});
}
}
// 현재 설정에서 차이값만 추출 // 현재 설정에서 차이값만 추출
const fullConfig = comp.componentConfig || {}; const fullConfig = comp.componentConfig || {};
const configOverrides = extractCustomConfig(fullConfig, defaults); const configOverrides = extractCustomConfig(fullConfig, defaults);
// 🔧 디버그: style 저장 확인 (주석 처리)
// if (comp.style?.labelDisplay !== undefined || configOverrides.style?.labelDisplay !== undefined) { console.log("💾 저장 시 style 변환:", { componentId: comp.id, "comp.style": comp.style, "configOverrides.style": configOverrides.style, "topLevelProps.style": topLevelProps.style }); }
// 상위 레벨 속성과 componentConfig 병합 // 상위 레벨 속성과 componentConfig 병합
const overrides = { ...topLevelProps, ...configOverrides }; // 🔧 style은 양쪽을 병합하되 comp.style(topLevelProps.style)을 우선시
const mergedStyle = {
...(configOverrides.style || {}),
...(topLevelProps.style || {}),
};
// 🔧 webTypeConfig도 병합 (topLevelProps가 우선, dataflowConfig 등 보존)
const mergedWebTypeConfig = {
...(configOverrides.webTypeConfig || {}),
...(topLevelProps.webTypeConfig || {}),
};
const overrides = {
...topLevelProps,
...configOverrides,
// 🆕 병합된 style 사용 (comp.style 값이 최종 우선)
...(Object.keys(mergedStyle).length > 0 ? { style: mergedStyle } : {}),
// 🆕 병합된 webTypeConfig 사용 (comp.webTypeConfig가 최종 우선)
...(Object.keys(mergedWebTypeConfig).length > 0 ? { webTypeConfig: mergedWebTypeConfig } : {}),
};
return { return {
id: comp.id, id: comp.id,

View File

@ -15,7 +15,8 @@ const nextConfig = {
// 실험적 기능 활성화 // 실험적 기능 활성화
experimental: { experimental: {
outputFileTracingRoot: undefined, // 메모리 사용량 최적화 (Next.js 15+)
webpackMemoryOptimizations: true,
}, },
// API 프록시 설정 - 백엔드로 요청 전달 // API 프록시 설정 - 백엔드로 요청 전달