- {/* 좌측 통합 툴바 */}
-
+
+
+ {/* 상단 슬림 툴바 */}
+
+ {/* 메인 컨테이너 (좌측 툴바 + 패널들 + 캔버스) */}
+
+ {/* 좌측 통합 툴바 */}
+
- {/* 통합 패널 */}
- {panelStates.unified?.isOpen && (
-
-
-
패널
- closePanel("unified")}
- className="text-muted-foreground hover:text-foreground focus-visible:ring-ring rounded-sm transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
- >
- ✕
-
-
-
-
-
-
- 컴포넌트
-
-
- 편집
-
-
-
-
- {
- const dragData = {
- type: column ? "column" : "table",
- table,
- column,
- };
- e.dataTransfer.setData("application/json", JSON.stringify(dragData));
- }}
- selectedTableName={selectedScreen.tableName}
- placedColumns={placedColumns}
- />
-
-
-
- 0 ? tables[0] : undefined}
- currentTableName={selectedScreen?.tableName}
- dragState={dragState}
- onStyleChange={(style) => {
- if (selectedComponent) {
- updateComponentProperty(selectedComponent.id, "style", style);
- }
- }}
- currentResolution={screenResolution}
- onResolutionChange={handleResolutionChange}
- allComponents={layout.components} // 🆕 플로우 위젯 감지용
- menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
- />
-
-
-
-
- )}
-
- {/* 메인 캔버스 영역 (스크롤 가능한 컨테이너) - 좌우 최소화, 위아래 넉넉한 여유 */}
-
- {/* Pan 모드 안내 - 제거됨 */}
- {/* 줌 레벨 표시 */}
-
- 🔍 {Math.round(zoomLevel * 100)}%
-
- {/* 🆕 플로우 버튼 그룹 제어 (버튼 선택 시 표시) */}
- {(() => {
- // 선택된 컴포넌트들
- const selectedComps = layout.components.filter((c) => groupState.selectedComponents.includes(c.id));
-
- // 버튼 컴포넌트만 필터링
- const selectedButtons = selectedComps.filter((comp) => areAllButtons([comp]));
-
- // 플로우 그룹에 속한 버튼이 있는지 확인
- const hasFlowGroupButton = selectedButtons.some((btn) => {
- const flowConfig = (btn as any).webTypeConfig?.flowVisibilityConfig;
- return flowConfig?.enabled && flowConfig.layoutBehavior === "auto-compact" && flowConfig.groupId;
- });
-
- // 버튼이 선택되었거나 플로우 그룹 버튼이 있으면 표시
- const shouldShow = selectedButtons.length >= 1 && (selectedButtons.length >= 2 || hasFlowGroupButton);
-
- if (!shouldShow) return null;
-
- return (
-
-
-
-
-
-
-
-
-
{selectedButtons.length}개 버튼 선택됨
-
-
- {/* 그룹 생성 버튼 (2개 이상 선택 시) */}
- {selectedButtons.length >= 2 && (
-
-
-
-
-
-
- 플로우 그룹으로 묶기
-
- )}
-
- {/* 그룹 해제 버튼 (플로우 그룹 버튼이 있으면 항상 표시) */}
- {hasFlowGroupButton && (
-
-
-
-
-
-
-
- 그룹 해제
-
- )}
-
- {/* 상태 표시 */}
- {hasFlowGroupButton &&
✓ 플로우 그룹 버튼
}
-
+ {/* 통합 패널 */}
+ {panelStates.unified?.isOpen && (
+
+
+
패널
+ closePanel("unified")}
+ className="text-muted-foreground hover:text-foreground focus-visible:ring-ring rounded-sm transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
+ >
+ ✕
+
- );
- })()}
- {/* 🔥 줌 적용 시 스크롤 영역 확보를 위한 래퍼 - 중앙 정렬로 변경 */}
-
- {/* 실제 작업 캔버스 (해상도 크기) - 고정 크기 + 줌 적용 */}
+
+
+
+
+ 컴포넌트
+
+
+ 편집
+
+
+
+
+ {
+ const dragData = {
+ type: column ? "column" : "table",
+ table,
+ column,
+ };
+ e.dataTransfer.setData("application/json", JSON.stringify(dragData));
+ }}
+ selectedTableName={selectedScreen.tableName}
+ placedColumns={placedColumns}
+ />
+
+
+
+ 0 ? tables[0] : undefined}
+ currentTableName={selectedScreen?.tableName}
+ dragState={dragState}
+ onStyleChange={(style) => {
+ if (selectedComponent) {
+ updateComponentProperty(selectedComponent.id, "style", style);
+ }
+ }}
+ currentResolution={screenResolution}
+ onResolutionChange={handleResolutionChange}
+ allComponents={layout.components} // 🆕 플로우 위젯 감지용
+ menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
+ />
+
+
+
+
+ )}
+
+ {/* 메인 캔버스 영역 (스크롤 가능한 컨테이너) - 좌우 최소화, 위아래 넉넉한 여유 */}
+
+ {/* Pan 모드 안내 - 제거됨 */}
+ {/* 줌 레벨 표시 */}
+
+ 🔍 {Math.round(zoomLevel * 100)}%
+
+ {/* 🆕 플로우 버튼 그룹 제어 (버튼 선택 시 표시) */}
+ {(() => {
+ // 선택된 컴포넌트들
+ const selectedComps = layout.components.filter((c) => groupState.selectedComponents.includes(c.id));
+
+ // 버튼 컴포넌트만 필터링
+ const selectedButtons = selectedComps.filter((comp) => areAllButtons([comp]));
+
+ // 플로우 그룹에 속한 버튼이 있는지 확인
+ const hasFlowGroupButton = selectedButtons.some((btn) => {
+ const flowConfig = (btn as any).webTypeConfig?.flowVisibilityConfig;
+ return flowConfig?.enabled && flowConfig.layoutBehavior === "auto-compact" && flowConfig.groupId;
+ });
+
+ // 버튼이 선택되었거나 플로우 그룹 버튼이 있으면 표시
+ const shouldShow = selectedButtons.length >= 1 && (selectedButtons.length >= 2 || hasFlowGroupButton);
+
+ if (!shouldShow) return null;
+
+ return (
+
+
+
+
+
+
+
+
+
{selectedButtons.length}개 버튼 선택됨
+
+
+ {/* 그룹 생성 버튼 (2개 이상 선택 시) */}
+ {selectedButtons.length >= 2 && (
+
+
+
+
+
+
+ 플로우 그룹으로 묶기
+
+ )}
+
+ {/* 그룹 해제 버튼 (플로우 그룹 버튼이 있으면 항상 표시) */}
+ {hasFlowGroupButton && (
+
+
+
+
+
+
+
+ 그룹 해제
+
+ )}
+
+ {/* 상태 표시 */}
+ {hasFlowGroupButton &&
✓ 플로우 그룹 버튼
}
+
+
+ );
+ })()}
+ {/* 🔥 줌 적용 시 스크롤 영역 확보를 위한 래퍼 - 중앙 정렬로 변경 */}
+ {/* 실제 작업 캔버스 (해상도 크기) - 고정 크기 + 줌 적용 */}
{
- if (e.target === e.currentTarget && !selectionDrag.wasSelecting && !isPanMode) {
- setSelectedComponent(null);
- setGroupState((prev) => ({ ...prev, selectedComponents: [] }));
- }
- }}
- onMouseDown={(e) => {
- // Pan 모드가 아닐 때만 다중 선택 시작
- if (e.target === e.currentTarget && !isPanMode) {
- startSelectionDrag(e);
- }
- }}
- onDragOver={(e) => {
- e.preventDefault();
- e.dataTransfer.dropEffect = "copy";
- }}
- onDrop={(e) => {
- e.preventDefault();
- // console.log("🎯 캔버스 드롭 이벤트 발생");
- handleDrop(e);
+ className="bg-background border-border border shadow-lg"
+ style={{
+ width: `${screenResolution.width}px`,
+ height: `${screenResolution.height}px`,
+ minWidth: `${screenResolution.width}px`,
+ maxWidth: `${screenResolution.width}px`,
+ minHeight: `${screenResolution.height}px`,
+ flexShrink: 0,
+ transform: `scale(${zoomLevel})`,
+ transformOrigin: "top center", // 중앙 기준으로 스케일
}}
>
- {/* 격자 라인 */}
- {gridLines.map((line, index) => (
-
- ))}
-
- {/* 컴포넌트들 */}
- {(() => {
- // 🆕 플로우 버튼 그룹 감지 및 처리
- const topLevelComponents = layout.components.filter((component) => !component.parentId);
-
- // auto-compact 모드의 버튼들을 그룹별로 묶기
- const buttonGroups: Record
= {};
- const processedButtonIds = new Set();
-
- topLevelComponents.forEach((component) => {
- const isButton =
- component.type === "button" ||
- (component.type === "component" &&
- ["button-primary", "button-secondary"].includes((component as any).componentType));
-
- if (isButton) {
- const flowConfig = (component as any).webTypeConfig?.flowVisibilityConfig as
- | FlowVisibilityConfig
- | undefined;
-
- if (flowConfig?.enabled && flowConfig.layoutBehavior === "auto-compact" && flowConfig.groupId) {
- if (!buttonGroups[flowConfig.groupId]) {
- buttonGroups[flowConfig.groupId] = [];
- }
- buttonGroups[flowConfig.groupId].push(component);
- processedButtonIds.add(component.id);
- }
+ {
+ if (e.target === e.currentTarget && !selectionDrag.wasSelecting && !isPanMode) {
+ setSelectedComponent(null);
+ setGroupState((prev) => ({ ...prev, selectedComponents: [] }));
}
- });
+ }}
+ onMouseDown={(e) => {
+ // Pan 모드가 아닐 때만 다중 선택 시작
+ if (e.target === e.currentTarget && !isPanMode) {
+ startSelectionDrag(e);
+ }
+ }}
+ onDragOver={(e) => {
+ e.preventDefault();
+ e.dataTransfer.dropEffect = "copy";
+ }}
+ onDrop={(e) => {
+ e.preventDefault();
+ // console.log("🎯 캔버스 드롭 이벤트 발생");
+ handleDrop(e);
+ }}
+ >
+ {/* 격자 라인 */}
+ {gridLines.map((line, index) => (
+
+ ))}
- // 그룹에 속하지 않은 일반 컴포넌트들
- const regularComponents = topLevelComponents.filter((c) => !processedButtonIds.has(c.id));
+ {/* 컴포넌트들 */}
+ {(() => {
+ // 🆕 플로우 버튼 그룹 감지 및 처리
+ const topLevelComponents = layout.components.filter((component) => !component.parentId);
- return (
- <>
- {/* 일반 컴포넌트들 */}
- {regularComponents.map((component) => {
- const children =
- component.type === "group"
- ? layout.components.filter((child) => child.parentId === component.id)
- : [];
+ // auto-compact 모드의 버튼들을 그룹별로 묶기
+ const buttonGroups: Record
= {};
+ const processedButtonIds = new Set();
- // 드래그 중 시각적 피드백 (다중 선택 지원)
- const isDraggingThis =
- dragState.isDragging && dragState.draggedComponent?.id === component.id;
- const isBeingDragged =
- dragState.isDragging &&
- dragState.draggedComponents.some((dragComp) => dragComp.id === component.id);
+ topLevelComponents.forEach((component) => {
+ const isButton =
+ component.type === "button" ||
+ (component.type === "component" &&
+ ["button-primary", "button-secondary"].includes((component as any).componentType));
- let displayComponent = component;
+ if (isButton) {
+ const flowConfig = (component as any).webTypeConfig?.flowVisibilityConfig as
+ | FlowVisibilityConfig
+ | undefined;
- if (isBeingDragged) {
- if (isDraggingThis) {
- // 주 드래그 컴포넌트: 마우스 위치 기반으로 실시간 위치 업데이트
- displayComponent = {
- ...component,
- position: dragState.currentPosition,
- style: {
- ...component.style,
- opacity: 0.8,
- transform: "scale(1.02)",
- transition: "none",
- zIndex: 50,
- },
- };
- } else {
- // 다른 선택된 컴포넌트들: 상대적 위치로 실시간 업데이트
- const originalComponent = dragState.draggedComponents.find(
- (dragComp) => dragComp.id === component.id,
- );
- if (originalComponent) {
- const deltaX = dragState.currentPosition.x - dragState.originalPosition.x;
- const deltaY = dragState.currentPosition.y - dragState.originalPosition.y;
+ if (
+ flowConfig?.enabled &&
+ flowConfig.layoutBehavior === "auto-compact" &&
+ flowConfig.groupId
+ ) {
+ if (!buttonGroups[flowConfig.groupId]) {
+ buttonGroups[flowConfig.groupId] = [];
+ }
+ buttonGroups[flowConfig.groupId].push(component);
+ processedButtonIds.add(component.id);
+ }
+ }
+ });
+ // 그룹에 속하지 않은 일반 컴포넌트들
+ const regularComponents = topLevelComponents.filter((c) => !processedButtonIds.has(c.id));
+
+ return (
+ <>
+ {/* 일반 컴포넌트들 */}
+ {regularComponents.map((component) => {
+ const children =
+ component.type === "group"
+ ? layout.components.filter((child) => child.parentId === component.id)
+ : [];
+
+ // 드래그 중 시각적 피드백 (다중 선택 지원)
+ const isDraggingThis =
+ dragState.isDragging && dragState.draggedComponent?.id === component.id;
+ const isBeingDragged =
+ dragState.isDragging &&
+ dragState.draggedComponents.some((dragComp) => dragComp.id === component.id);
+
+ let displayComponent = component;
+
+ if (isBeingDragged) {
+ if (isDraggingThis) {
+ // 주 드래그 컴포넌트: 마우스 위치 기반으로 실시간 위치 업데이트
displayComponent = {
...component,
- position: {
- x: originalComponent.position.x + deltaX,
- y: originalComponent.position.y + deltaY,
- z: originalComponent.position.z || 1,
- } as Position,
+ position: dragState.currentPosition,
style: {
...component.style,
opacity: 0.8,
+ transform: "scale(1.02)",
transition: "none",
- zIndex: 40, // 주 컴포넌트보다 약간 낮게
+ zIndex: 50,
},
};
+ } else {
+ // 다른 선택된 컴포넌트들: 상대적 위치로 실시간 업데이트
+ const originalComponent = dragState.draggedComponents.find(
+ (dragComp) => dragComp.id === component.id,
+ );
+ if (originalComponent) {
+ const deltaX = dragState.currentPosition.x - dragState.originalPosition.x;
+ const deltaY = dragState.currentPosition.y - dragState.originalPosition.y;
+
+ displayComponent = {
+ ...component,
+ position: {
+ x: originalComponent.position.x + deltaX,
+ y: originalComponent.position.y + deltaY,
+ z: originalComponent.position.z || 1,
+ } as Position,
+ style: {
+ ...component.style,
+ opacity: 0.8,
+ transition: "none",
+ zIndex: 40, // 주 컴포넌트보다 약간 낮게
+ },
+ };
+ }
}
}
- }
- // 전역 파일 상태도 key에 포함하여 실시간 리렌더링
- const globalFileState =
- typeof window !== "undefined" ? (window as any).globalFileState || {} : {};
- const globalFiles = globalFileState[component.id] || [];
- const componentFiles = (component as any).uploadedFiles || [];
- const fileStateKey = `${globalFiles.length}-${JSON.stringify(globalFiles.map((f: any) => f.objid) || [])}-${componentFiles.length}`;
+ // 전역 파일 상태도 key에 포함하여 실시간 리렌더링
+ const globalFileState =
+ typeof window !== "undefined" ? (window as any).globalFileState || {} : {};
+ const globalFiles = globalFileState[component.id] || [];
+ const componentFiles = (component as any).uploadedFiles || [];
+ const fileStateKey = `${globalFiles.length}-${JSON.stringify(globalFiles.map((f: any) => f.objid) || [])}-${componentFiles.length}`;
- return (
- handleComponentClick(component, e)}
- onDoubleClick={(e) => handleComponentDoubleClick(component, e)}
- onDragStart={(e) => startComponentDrag(component, e)}
- onDragEnd={endDrag}
- selectedScreen={selectedScreen}
- menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
- // onZoneComponentDrop 제거
- onZoneClick={handleZoneClick}
- // 설정 변경 핸들러 (테이블 페이지 크기 등 설정을 상세설정에 반영)
- onConfigChange={(config) => {
- // console.log("📤 테이블 설정 변경을 상세설정에 반영:", config);
+ return (
+ handleComponentClick(component, e)}
+ onDoubleClick={(e) => handleComponentDoubleClick(component, e)}
+ onDragStart={(e) => startComponentDrag(component, e)}
+ onDragEnd={endDrag}
+ selectedScreen={selectedScreen}
+ menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
+ // onZoneComponentDrop 제거
+ onZoneClick={handleZoneClick}
+ // 설정 변경 핸들러 (테이블 페이지 크기 등 설정을 상세설정에 반영)
+ onConfigChange={(config) => {
+ // console.log("📤 테이블 설정 변경을 상세설정에 반영:", config);
- // 컴포넌트의 componentConfig 업데이트
- const updatedComponents = layout.components.map((comp) => {
- if (comp.id === component.id) {
- return {
- ...comp,
- componentConfig: {
- ...comp.componentConfig,
- ...config,
- },
- };
- }
- return comp;
- });
+ // 컴포넌트의 componentConfig 업데이트
+ const updatedComponents = layout.components.map((comp) => {
+ if (comp.id === component.id) {
+ return {
+ ...comp,
+ componentConfig: {
+ ...comp.componentConfig,
+ ...config,
+ },
+ };
+ }
+ return comp;
+ });
- const newLayout = {
- ...layout,
- components: updatedComponents,
- };
+ const newLayout = {
+ ...layout,
+ components: updatedComponents,
+ };
- setLayout(newLayout);
- saveToHistory(newLayout);
+ setLayout(newLayout);
+ saveToHistory(newLayout);
- console.log("✅ 컴포넌트 설정 업데이트 완료:", {
- componentId: component.id,
- updatedConfig: config,
- });
- }}
- >
- {/* 컨테이너, 그룹, 영역의 자식 컴포넌트들 렌더링 (레이아웃은 독립적으로 렌더링) */}
- {(component.type === "group" ||
- component.type === "container" ||
- component.type === "area") &&
- layout.components
- .filter((child) => child.parentId === component.id)
- .map((child) => {
- // 자식 컴포넌트에도 드래그 피드백 적용
- const isChildDraggingThis =
- dragState.isDragging && dragState.draggedComponent?.id === child.id;
- const isChildBeingDragged =
+ console.log("✅ 컴포넌트 설정 업데이트 완료:", {
+ componentId: component.id,
+ updatedConfig: config,
+ });
+ }}
+ >
+ {/* 컨테이너, 그룹, 영역의 자식 컴포넌트들 렌더링 (레이아웃은 독립적으로 렌더링) */}
+ {(component.type === "group" ||
+ component.type === "container" ||
+ component.type === "area") &&
+ layout.components
+ .filter((child) => child.parentId === component.id)
+ .map((child) => {
+ // 자식 컴포넌트에도 드래그 피드백 적용
+ const isChildDraggingThis =
+ dragState.isDragging && dragState.draggedComponent?.id === child.id;
+ const isChildBeingDragged =
+ dragState.isDragging &&
+ dragState.draggedComponents.some((dragComp) => dragComp.id === child.id);
+
+ let displayChild = child;
+
+ if (isChildBeingDragged) {
+ if (isChildDraggingThis) {
+ // 주 드래그 자식 컴포넌트
+ displayChild = {
+ ...child,
+ position: dragState.currentPosition,
+ style: {
+ ...child.style,
+ opacity: 0.8,
+ transform: "scale(1.02)",
+ transition: "none",
+ zIndex: 50,
+ },
+ };
+ } else {
+ // 다른 선택된 자식 컴포넌트들
+ const originalChildComponent = dragState.draggedComponents.find(
+ (dragComp) => dragComp.id === child.id,
+ );
+ if (originalChildComponent) {
+ const deltaX = dragState.currentPosition.x - dragState.originalPosition.x;
+ const deltaY = dragState.currentPosition.y - dragState.originalPosition.y;
+
+ displayChild = {
+ ...child,
+ position: {
+ x: originalChildComponent.position.x + deltaX,
+ y: originalChildComponent.position.y + deltaY,
+ z: originalChildComponent.position.z || 1,
+ } as Position,
+ style: {
+ ...child.style,
+ opacity: 0.8,
+ transition: "none",
+ zIndex: 8888,
+ },
+ };
+ }
+ }
+ }
+
+ // 자식 컴포넌트의 위치를 부모 기준 상대 좌표로 조정
+ const relativeChildComponent = {
+ ...displayChild,
+ position: {
+ x: displayChild.position.x - component.position.x,
+ y: displayChild.position.y - component.position.y,
+ z: displayChild.position.z || 1,
+ },
+ };
+
+ return (
+ f.objid) || [])}`}
+ component={relativeChildComponent}
+ isSelected={
+ selectedComponent?.id === child.id ||
+ groupState.selectedComponents.includes(child.id)
+ }
+ isDesignMode={true} // 편집 모드로 설정
+ onClick={(e) => handleComponentClick(child, e)}
+ onDoubleClick={(e) => handleComponentDoubleClick(child, e)}
+ onDragStart={(e) => startComponentDrag(child, e)}
+ onDragEnd={endDrag}
+ selectedScreen={selectedScreen}
+ // onZoneComponentDrop 제거
+ onZoneClick={handleZoneClick}
+ // 설정 변경 핸들러 (자식 컴포넌트용)
+ onConfigChange={(config) => {
+ // console.log("📤 자식 컴포넌트 설정 변경을 상세설정에 알림:", config);
+ // TODO: 실제 구현은 DetailSettingsPanel과의 연동 필요
+ }}
+ />
+ );
+ })}
+
+ );
+ })}
+
+ {/* 🆕 플로우 버튼 그룹들 */}
+ {Object.entries(buttonGroups).map(([groupId, buttons]) => {
+ if (buttons.length === 0) return null;
+
+ const firstButton = buttons[0];
+ const groupConfig = (firstButton as any).webTypeConfig
+ ?.flowVisibilityConfig as FlowVisibilityConfig;
+
+ // 🔧 그룹의 위치 및 크기 계산
+ // 모든 버튼이 같은 위치(groupX, groupY)에 배치되어 있으므로
+ // 첫 번째 버튼의 위치를 그룹 시작점으로 사용
+ const direction = groupConfig.groupDirection || "horizontal";
+ const gap = groupConfig.groupGap ?? 8;
+ const align = groupConfig.groupAlign || "start";
+
+ const groupPosition = {
+ x: buttons[0].position.x,
+ y: buttons[0].position.y,
+ z: buttons[0].position.z || 2,
+ };
+
+ // 버튼들의 실제 크기 계산
+ let groupWidth = 0;
+ let groupHeight = 0;
+
+ if (direction === "horizontal") {
+ // 가로 정렬: 모든 버튼의 너비 + 간격
+ groupWidth = buttons.reduce((total, button, index) => {
+ const buttonWidth = button.size?.width || 100;
+ const gapWidth = index < buttons.length - 1 ? gap : 0;
+ return total + buttonWidth + gapWidth;
+ }, 0);
+ groupHeight = Math.max(...buttons.map((b) => b.size?.height || 40));
+ } else {
+ // 세로 정렬
+ groupWidth = Math.max(...buttons.map((b) => b.size?.width || 100));
+ groupHeight = buttons.reduce((total, button, index) => {
+ const buttonHeight = button.size?.height || 40;
+ const gapHeight = index < buttons.length - 1 ? gap : 0;
+ return total + buttonHeight + gapHeight;
+ }, 0);
+ }
+
+ // 🆕 그룹 전체가 선택되었는지 확인
+ const isGroupSelected = buttons.every(
+ (btn) =>
+ selectedComponent?.id === btn.id || groupState.selectedComponents.includes(btn.id),
+ );
+ const hasAnySelected = buttons.some(
+ (btn) =>
+ selectedComponent?.id === btn.id || groupState.selectedComponents.includes(btn.id),
+ );
+
+ return (
+
+
{
+ // 드래그 피드백
+ const isDraggingThis =
+ dragState.isDragging && dragState.draggedComponent?.id === button.id;
+ const isBeingDragged =
dragState.isDragging &&
- dragState.draggedComponents.some((dragComp) => dragComp.id === child.id);
+ dragState.draggedComponents.some((dragComp) => dragComp.id === button.id);
- let displayChild = child;
+ let displayButton = button;
- if (isChildBeingDragged) {
- if (isChildDraggingThis) {
- // 주 드래그 자식 컴포넌트
- displayChild = {
- ...child,
+ if (isBeingDragged) {
+ if (isDraggingThis) {
+ displayButton = {
+ ...button,
position: dragState.currentPosition,
style: {
- ...child.style,
+ ...button.style,
opacity: 0.8,
transform: "scale(1.02)",
transition: "none",
zIndex: 50,
},
};
- } else {
- // 다른 선택된 자식 컴포넌트들
- const originalChildComponent = dragState.draggedComponents.find(
- (dragComp) => dragComp.id === child.id,
- );
- if (originalChildComponent) {
- const deltaX = dragState.currentPosition.x - dragState.originalPosition.x;
- const deltaY = dragState.currentPosition.y - dragState.originalPosition.y;
-
- displayChild = {
- ...child,
- position: {
- x: originalChildComponent.position.x + deltaX,
- y: originalChildComponent.position.y + deltaY,
- z: originalChildComponent.position.z || 1,
- } as Position,
- style: {
- ...child.style,
- opacity: 0.8,
- transition: "none",
- zIndex: 8888,
- },
- };
- }
}
}
- // 자식 컴포넌트의 위치를 부모 기준 상대 좌표로 조정
- const relativeChildComponent = {
- ...displayChild,
+ // 🔧 그룹 내부에서는 상대 위치 사용 (wrapper로 처리)
+ const relativeButton = {
+ ...displayButton,
position: {
- x: displayChild.position.x - component.position.x,
- y: displayChild.position.y - component.position.y,
- z: displayChild.position.z || 1,
+ x: 0,
+ y: 0,
+ z: displayButton.position.z || 1,
},
};
return (
- f.objid) || [])}`}
- component={relativeChildComponent}
- isSelected={
- selectedComponent?.id === child.id ||
- groupState.selectedComponents.includes(child.id)
- }
- isDesignMode={true} // 편집 모드로 설정
- onClick={(e) => handleComponentClick(child, e)}
- onDoubleClick={(e) => handleComponentDoubleClick(child, e)}
- onDragStart={(e) => startComponentDrag(child, e)}
- onDragEnd={endDrag}
- selectedScreen={selectedScreen}
- // onZoneComponentDrop 제거
- onZoneClick={handleZoneClick}
- // 설정 변경 핸들러 (자식 컴포넌트용)
- onConfigChange={(config) => {
- // console.log("📤 자식 컴포넌트 설정 변경을 상세설정에 알림:", config);
- // TODO: 실제 구현은 DetailSettingsPanel과의 연동 필요
+
- );
- })}
-
- );
- })}
+ onMouseDown={(e) => {
+ // 클릭이 아닌 드래그인 경우에만 드래그 시작
+ e.preventDefault();
+ e.stopPropagation();
- {/* 🆕 플로우 버튼 그룹들 */}
- {Object.entries(buttonGroups).map(([groupId, buttons]) => {
- if (buttons.length === 0) return null;
+ const startX = e.clientX;
+ const startY = e.clientY;
+ let isDragging = false;
+ let dragStarted = false;
- const firstButton = buttons[0];
- const groupConfig = (firstButton as any).webTypeConfig
- ?.flowVisibilityConfig as FlowVisibilityConfig;
+ const handleMouseMove = (moveEvent: MouseEvent) => {
+ const deltaX = Math.abs(moveEvent.clientX - startX);
+ const deltaY = Math.abs(moveEvent.clientY - startY);
- // 🔧 그룹의 위치 및 크기 계산
- // 모든 버튼이 같은 위치(groupX, groupY)에 배치되어 있으므로
- // 첫 번째 버튼의 위치를 그룹 시작점으로 사용
- const direction = groupConfig.groupDirection || "horizontal";
- const gap = groupConfig.groupGap ?? 8;
- const align = groupConfig.groupAlign || "start";
+ // 5픽셀 이상 움직이면 드래그로 간주
+ if ((deltaX > 5 || deltaY > 5) && !dragStarted) {
+ isDragging = true;
+ dragStarted = true;
- const groupPosition = {
- x: buttons[0].position.x,
- y: buttons[0].position.y,
- z: buttons[0].position.z || 2,
- };
+ // Shift 키를 누르지 않았으면 같은 그룹의 버튼들도 모두 선택
+ if (!e.shiftKey) {
+ const buttonIds = buttons.map((b) => b.id);
+ setGroupState((prev) => ({
+ ...prev,
+ selectedComponents: buttonIds,
+ }));
+ }
- // 버튼들의 실제 크기 계산
- let groupWidth = 0;
- let groupHeight = 0;
-
- if (direction === "horizontal") {
- // 가로 정렬: 모든 버튼의 너비 + 간격
- groupWidth = buttons.reduce((total, button, index) => {
- const buttonWidth = button.size?.width || 100;
- const gapWidth = index < buttons.length - 1 ? gap : 0;
- return total + buttonWidth + gapWidth;
- }, 0);
- groupHeight = Math.max(...buttons.map((b) => b.size?.height || 40));
- } else {
- // 세로 정렬
- groupWidth = Math.max(...buttons.map((b) => b.size?.width || 100));
- groupHeight = buttons.reduce((total, button, index) => {
- const buttonHeight = button.size?.height || 40;
- const gapHeight = index < buttons.length - 1 ? gap : 0;
- return total + buttonHeight + gapHeight;
- }, 0);
- }
-
- // 🆕 그룹 전체가 선택되었는지 확인
- const isGroupSelected = buttons.every(
- (btn) => selectedComponent?.id === btn.id || groupState.selectedComponents.includes(btn.id),
- );
- const hasAnySelected = buttons.some(
- (btn) => selectedComponent?.id === btn.id || groupState.selectedComponents.includes(btn.id),
- );
-
- return (
-
-
{
- // 드래그 피드백
- const isDraggingThis =
- dragState.isDragging && dragState.draggedComponent?.id === button.id;
- const isBeingDragged =
- dragState.isDragging &&
- dragState.draggedComponents.some((dragComp) => dragComp.id === button.id);
-
- let displayButton = button;
-
- if (isBeingDragged) {
- if (isDraggingThis) {
- displayButton = {
- ...button,
- position: dragState.currentPosition,
- style: {
- ...button.style,
- opacity: 0.8,
- transform: "scale(1.02)",
- transition: "none",
- zIndex: 50,
- },
- };
- }
- }
-
- // 🔧 그룹 내부에서는 상대 위치 사용 (wrapper로 처리)
- const relativeButton = {
- ...displayButton,
- position: {
- x: 0,
- y: 0,
- z: displayButton.position.z || 1,
- },
- };
-
- return (
- {
- // 클릭이 아닌 드래그인 경우에만 드래그 시작
- e.preventDefault();
- e.stopPropagation();
-
- const startX = e.clientX;
- const startY = e.clientY;
- let isDragging = false;
- let dragStarted = false;
-
- const handleMouseMove = (moveEvent: MouseEvent) => {
- const deltaX = Math.abs(moveEvent.clientX - startX);
- const deltaY = Math.abs(moveEvent.clientY - startY);
-
- // 5픽셀 이상 움직이면 드래그로 간주
- if ((deltaX > 5 || deltaY > 5) && !dragStarted) {
- isDragging = true;
- dragStarted = true;
-
- // Shift 키를 누르지 않았으면 같은 그룹의 버튼들도 모두 선택
- if (!e.shiftKey) {
- const buttonIds = buttons.map((b) => b.id);
- setGroupState((prev) => ({
- ...prev,
- selectedComponents: buttonIds,
- }));
+ // 드래그 시작
+ startComponentDrag(button, e as any);
}
+ };
- // 드래그 시작
- startComponentDrag(button, e as any);
- }
- };
+ const handleMouseUp = () => {
+ document.removeEventListener("mousemove", handleMouseMove);
+ document.removeEventListener("mouseup", handleMouseUp);
- const handleMouseUp = () => {
- document.removeEventListener("mousemove", handleMouseMove);
- document.removeEventListener("mouseup", handleMouseUp);
-
- // 드래그가 아니면 클릭으로 처리
- if (!isDragging) {
- // Shift 키를 누르지 않았으면 같은 그룹의 버튼들도 모두 선택
- if (!e.shiftKey) {
- const buttonIds = buttons.map((b) => b.id);
- setGroupState((prev) => ({
- ...prev,
- selectedComponents: buttonIds,
- }));
+ // 드래그가 아니면 클릭으로 처리
+ if (!isDragging) {
+ // Shift 키를 누르지 않았으면 같은 그룹의 버튼들도 모두 선택
+ if (!e.shiftKey) {
+ const buttonIds = buttons.map((b) => b.id);
+ setGroupState((prev) => ({
+ ...prev,
+ selectedComponents: buttonIds,
+ }));
+ }
+ handleComponentClick(button, e);
}
- handleComponentClick(button, e);
- }
- };
+ };
- document.addEventListener("mousemove", handleMouseMove);
- document.addEventListener("mouseup", handleMouseUp);
- }}
- onDoubleClick={(e) => {
- e.stopPropagation();
- handleComponentDoubleClick(button, e);
- }}
- className={
- selectedComponent?.id === button.id ||
- groupState.selectedComponents.includes(button.id)
- ? "outline-1 outline-offset-1 outline-blue-400"
- : ""
- }
- >
- {/* 그룹 내부에서는 DynamicComponentRenderer로 직접 렌더링 */}
-
-
{}}
- />
+ document.addEventListener("mousemove", handleMouseMove);
+ document.addEventListener("mouseup", handleMouseUp);
+ }}
+ onDoubleClick={(e) => {
+ e.stopPropagation();
+ handleComponentDoubleClick(button, e);
+ }}
+ className={
+ selectedComponent?.id === button.id ||
+ groupState.selectedComponents.includes(button.id)
+ ? "outline-1 outline-offset-1 outline-blue-400"
+ : ""
+ }
+ >
+ {/* 그룹 내부에서는 DynamicComponentRenderer로 직접 렌더링 */}
+
+ {}}
+ />
+
-
- );
- }}
- />
-
- );
- })}
- >
- );
- })()}
+ );
+ }}
+ />
+
+ );
+ })}
+ >
+ );
+ })()}
- {/* 드래그 선택 영역 */}
- {selectionDrag.isSelecting && (
-
- )}
+ {/* 드래그 선택 영역 */}
+ {selectionDrag.isSelecting && (
+
+ )}
- {/* 빈 캔버스 안내 */}
- {layout.components.length === 0 && (
-
-
-
-
-
-
캔버스가 비어있습니다
-
- 좌측 패널에서 테이블/컬럼이나 템플릿을 드래그하여 화면을 설계하세요
-
-
-
- 단축키: T(테이블), M(템플릿), P(속성), S(스타일),
- R(격자), D(상세설정), E(해상도)
-
-
- 편집: Ctrl+C(복사), Ctrl+V(붙여넣기), Ctrl+S(저장),
- Ctrl+Z(실행취소), Delete(삭제)
-
-
- ⚠️
- 브라우저 기본 단축키가 차단되어 애플리케이션 기능만 작동합니다
+ {/* 빈 캔버스 안내 */}
+ {layout.components.length === 0 && (
+
+
+
+
+
+
캔버스가 비어있습니다
+
+ 좌측 패널에서 테이블/컬럼이나 템플릿을 드래그하여 화면을 설계하세요
+
+
+ 단축키: T(테이블), M(템플릿), P(속성), S(스타일),
+ R(격자), D(상세설정), E(해상도)
+
+
+ 편집: Ctrl+C(복사), Ctrl+V(붙여넣기), Ctrl+S(저장),
+ Ctrl+Z(실행취소), Delete(삭제)
+
+
+ ⚠️
+ 브라우저 기본 단축키가 차단되어 애플리케이션 기능만 작동합니다
+
+
-
- )}
+ )}
+
-
- {" "}
- {/* 🔥 줌 래퍼 닫기 */}
-
-
{" "}
- {/* 메인 컨테이너 닫기 */}
- {/* 🆕 플로우 버튼 그룹 생성 다이얼로그 */}
-
- {/* 모달들 */}
- {/* 메뉴 할당 모달 */}
- {showMenuAssignmentModal && selectedScreen && (
-
setShowMenuAssignmentModal(false)}
- onAssignmentComplete={() => {
- // 모달을 즉시 닫지 않고, MenuAssignmentModal이 3초 후 자동으로 닫히도록 함
- // setShowMenuAssignmentModal(false);
- // toast.success("메뉴에 화면이 할당되었습니다.");
- }}
- onBackToList={onBackToList}
+ {" "}
+ {/* 🔥 줌 래퍼 닫기 */}
+
+
{" "}
+ {/* 메인 컨테이너 닫기 */}
+ {/* 🆕 플로우 버튼 그룹 생성 다이얼로그 */}
+
- )}
- {/* 파일첨부 상세 모달 */}
- {showFileAttachmentModal && selectedFileComponent && (
-
{
- setShowFileAttachmentModal(false);
- setSelectedFileComponent(null);
- }}
- component={selectedFileComponent}
- screenId={selectedScreen.screenId}
- />
- )}
-
+ {/* 모달들 */}
+ {/* 메뉴 할당 모달 */}
+ {showMenuAssignmentModal && selectedScreen && (
+
setShowMenuAssignmentModal(false)}
+ onAssignmentComplete={() => {
+ // 모달을 즉시 닫지 않고, MenuAssignmentModal이 3초 후 자동으로 닫히도록 함
+ // setShowMenuAssignmentModal(false);
+ // toast.success("메뉴에 화면이 할당되었습니다.");
+ }}
+ onBackToList={onBackToList}
+ />
+ )}
+ {/* 파일첨부 상세 모달 */}
+ {showFileAttachmentModal && selectedFileComponent && (
+ {
+ setShowFileAttachmentModal(false);
+ setSelectedFileComponent(null);
+ }}
+ component={selectedFileComponent}
+ screenId={selectedScreen.screenId}
+ />
+ )}
+
+
);
}
diff --git a/frontend/components/screen/panels/ComponentsPanel.tsx b/frontend/components/screen/panels/ComponentsPanel.tsx
index 2e15c486..9d778383 100644
--- a/frontend/components/screen/panels/ComponentsPanel.tsx
+++ b/frontend/components/screen/panels/ComponentsPanel.tsx
@@ -6,7 +6,7 @@ import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";
import { ComponentDefinition, ComponentCategory } from "@/types/component";
-import { Search, Package, Grid, Layers, Palette, Zap, MousePointer, Edit3, BarChart3, Database } from "lucide-react";
+import { Search, Package, Grid, Layers, Palette, Zap, MousePointer, Edit3, BarChart3, Database, Wrench } from "lucide-react";
import { TableInfo, ColumnInfo } from "@/types/screen";
import TablesPanel from "./TablesPanel";
@@ -64,6 +64,7 @@ export function ComponentsPanel({
action: allComponents.filter((c) => c.category === ComponentCategory.ACTION),
display: allComponents.filter((c) => c.category === ComponentCategory.DISPLAY),
layout: allComponents.filter((c) => c.category === ComponentCategory.LAYOUT),
+ utility: allComponents.filter((c) => c.category === ComponentCategory.UTILITY), // 🆕 유틸리티 카테고리 추가
};
}, [allComponents]);
@@ -184,7 +185,7 @@ export function ComponentsPanel({
{/* 카테고리 탭 */}
-
+
레이아웃
+
+
+ 유틸리티
+
{/* 테이블 탭 */}
@@ -271,6 +280,13 @@ export function ComponentsPanel({
? getFilteredComponents("layout").map(renderComponentCard)
: renderEmptyState()}
+
+ {/* 유틸리티 컴포넌트 */}
+
+ {getFilteredComponents("utility").length > 0
+ ? getFilteredComponents("utility").map(renderComponentCard)
+ : renderEmptyState()}
+
{/* 도움말 */}
diff --git a/frontend/components/screen/table-options/ColumnVisibilityPanel.tsx b/frontend/components/screen/table-options/ColumnVisibilityPanel.tsx
new file mode 100644
index 00000000..aea67622
--- /dev/null
+++ b/frontend/components/screen/table-options/ColumnVisibilityPanel.tsx
@@ -0,0 +1,202 @@
+import React, { useState, useEffect } from "react";
+import { useTableOptions } from "@/contexts/TableOptionsContext";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { GripVertical, Eye, EyeOff } from "lucide-react";
+import { ColumnVisibility } from "@/types/table-options";
+
+interface Props {
+ tableId: string;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}
+
+export const ColumnVisibilityPanel: React.FC
= ({
+ tableId,
+ open,
+ onOpenChange,
+}) => {
+ const { getTable } = useTableOptions();
+ const table = getTable(tableId);
+
+ const [localColumns, setLocalColumns] = useState([]);
+
+ // 테이블 정보 로드
+ useEffect(() => {
+ if (table) {
+ setLocalColumns(
+ table.columns.map((col) => ({
+ columnName: col.columnName,
+ visible: col.visible,
+ width: col.width,
+ order: 0,
+ }))
+ );
+ }
+ }, [table]);
+
+ const handleVisibilityChange = (columnName: string, visible: boolean) => {
+ setLocalColumns((prev) =>
+ prev.map((col) =>
+ col.columnName === columnName ? { ...col, visible } : col
+ )
+ );
+ };
+
+ const handleWidthChange = (columnName: string, width: number) => {
+ setLocalColumns((prev) =>
+ prev.map((col) =>
+ col.columnName === columnName ? { ...col, width } : col
+ )
+ );
+ };
+
+ const handleApply = () => {
+ table?.onColumnVisibilityChange(localColumns);
+ onOpenChange(false);
+ };
+
+ const handleReset = () => {
+ if (table) {
+ setLocalColumns(
+ table.columns.map((col) => ({
+ columnName: col.columnName,
+ visible: true,
+ width: 150,
+ order: 0,
+ }))
+ );
+ }
+ };
+
+ const visibleCount = localColumns.filter((col) => col.visible).length;
+
+ return (
+
+
+
+
+ 테이블 옵션
+
+
+ 컬럼 표시/숨기기, 순서 변경, 너비 등을 설정할 수 있습니다. 모든
+ 테두리를 드래그하여 크기를 조정할 수 있습니다.
+
+
+
+
+ {/* 상태 표시 */}
+
+
+ {visibleCount}/{localColumns.length}개 컬럼 표시 중
+
+
+ 초기화
+
+
+
+ {/* 컬럼 리스트 */}
+
+
+ {localColumns.map((col) => {
+ const columnMeta = table?.columns.find(
+ (c) => c.columnName === col.columnName
+ );
+ return (
+
+ {/* 드래그 핸들 */}
+
+
+ {/* 체크박스 */}
+
+ handleVisibilityChange(
+ col.columnName,
+ checked as boolean
+ )
+ }
+ />
+
+ {/* 가시성 아이콘 */}
+ {col.visible ? (
+
+ ) : (
+
+ )}
+
+ {/* 컬럼명 */}
+
+
+ {columnMeta?.columnLabel}
+
+
+ {col.columnName}
+
+
+
+ {/* 너비 설정 */}
+
+
+ 너비:
+
+
+ handleWidthChange(
+ col.columnName,
+ parseInt(e.target.value) || 150
+ )
+ }
+ className="h-7 w-16 text-xs sm:h-8 sm:w-20 sm:text-sm"
+ min={50}
+ max={500}
+ />
+
+
+ );
+ })}
+
+
+
+
+
+ onOpenChange(false)}
+ className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
+ >
+ 취소
+
+
+ 저장
+
+
+
+
+ );
+};
+
diff --git a/frontend/components/screen/table-options/FilterPanel.tsx b/frontend/components/screen/table-options/FilterPanel.tsx
new file mode 100644
index 00000000..8af199f5
--- /dev/null
+++ b/frontend/components/screen/table-options/FilterPanel.tsx
@@ -0,0 +1,223 @@
+import React, { useState } from "react";
+import { useTableOptions } from "@/contexts/TableOptionsContext";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { Plus, X } from "lucide-react";
+import { TableFilter } from "@/types/table-options";
+
+interface Props {
+ tableId: string;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}
+
+export const FilterPanel: React.FC = ({
+ tableId,
+ open,
+ onOpenChange,
+}) => {
+ const { getTable } = useTableOptions();
+ const table = getTable(tableId);
+
+ const [activeFilters, setActiveFilters] = useState([]);
+
+ const addFilter = () => {
+ setActiveFilters([
+ ...activeFilters,
+ { columnName: "", operator: "contains", value: "" },
+ ]);
+ };
+
+ const removeFilter = (index: number) => {
+ setActiveFilters(activeFilters.filter((_, i) => i !== index));
+ };
+
+ const updateFilter = (
+ index: number,
+ field: keyof TableFilter,
+ value: any
+ ) => {
+ setActiveFilters(
+ activeFilters.map((filter, i) =>
+ i === index ? { ...filter, [field]: value } : filter
+ )
+ );
+ };
+
+ const applyFilters = () => {
+ // 빈 필터 제거
+ const validFilters = activeFilters.filter(
+ (f) => f.columnName && f.value !== ""
+ );
+ table?.onFilterChange(validFilters);
+ onOpenChange(false);
+ };
+
+ const clearFilters = () => {
+ setActiveFilters([]);
+ table?.onFilterChange([]);
+ };
+
+ const operatorLabels: Record = {
+ equals: "같음",
+ contains: "포함",
+ startsWith: "시작",
+ endsWith: "끝",
+ gt: "보다 큼",
+ lt: "보다 작음",
+ gte: "이상",
+ lte: "이하",
+ notEquals: "같지 않음",
+ };
+
+ return (
+
+
+
+
+ 검색 필터 설정
+
+
+ 검색 필터로 사용할 컬럼을 선택하세요. 선택한 컬럼의 검색 입력 필드가
+ 표시됩니다.
+
+
+
+
+ {/* 전체 선택/해제 */}
+
+
+ 총 {activeFilters.length}개의 검색 필터가 표시됩니다
+
+
+ 초기화
+
+
+
+ {/* 필터 리스트 */}
+
+
+ {activeFilters.map((filter, index) => (
+
+ {/* 컬럼 선택 */}
+
+ updateFilter(index, "columnName", val)
+ }
+ >
+
+
+
+
+ {table?.columns
+ .filter((col) => col.filterable !== false)
+ .map((col) => (
+
+ {col.columnLabel}
+
+ ))}
+
+
+
+ {/* 연산자 선택 */}
+
+ updateFilter(index, "operator", val)
+ }
+ >
+
+
+
+
+ {Object.entries(operatorLabels).map(([value, label]) => (
+
+ {label}
+
+ ))}
+
+
+
+ {/* 값 입력 */}
+
+ updateFilter(index, "value", e.target.value)
+ }
+ placeholder="값 입력"
+ className="h-8 flex-1 text-xs sm:h-9 sm:text-sm"
+ />
+
+ {/* 삭제 버튼 */}
+ removeFilter(index)}
+ className="h-8 w-8 shrink-0 sm:h-9 sm:w-9"
+ >
+
+
+
+ ))}
+
+
+
+ {/* 필터 추가 버튼 */}
+
+
+ 필터 추가
+
+
+
+
+ onOpenChange(false)}
+ className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
+ >
+ 취소
+
+
+ 저장
+
+
+
+
+ );
+};
+
diff --git a/frontend/components/screen/table-options/GroupingPanel.tsx b/frontend/components/screen/table-options/GroupingPanel.tsx
new file mode 100644
index 00000000..fb2bb22d
--- /dev/null
+++ b/frontend/components/screen/table-options/GroupingPanel.tsx
@@ -0,0 +1,159 @@
+import React, { useState } from "react";
+import { useTableOptions } from "@/contexts/TableOptionsContext";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Checkbox } from "@/components/ui/checkbox";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { ArrowRight } from "lucide-react";
+
+interface Props {
+ tableId: string;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}
+
+export const GroupingPanel: React.FC = ({
+ tableId,
+ open,
+ onOpenChange,
+}) => {
+ const { getTable } = useTableOptions();
+ const table = getTable(tableId);
+
+ const [selectedColumns, setSelectedColumns] = useState([]);
+
+ const toggleColumn = (columnName: string) => {
+ if (selectedColumns.includes(columnName)) {
+ setSelectedColumns(selectedColumns.filter((c) => c !== columnName));
+ } else {
+ setSelectedColumns([...selectedColumns, columnName]);
+ }
+ };
+
+ const applyGrouping = () => {
+ table?.onGroupChange(selectedColumns);
+ onOpenChange(false);
+ };
+
+ const clearGrouping = () => {
+ setSelectedColumns([]);
+ table?.onGroupChange([]);
+ };
+
+ return (
+
+
+
+ 그룹 설정
+
+ 데이터를 그룹화할 컬럼을 선택하세요
+
+
+
+
+ {/* 상태 표시 */}
+
+
+ {selectedColumns.length}개 컬럼으로 그룹화
+
+
+ 초기화
+
+
+
+ {/* 컬럼 리스트 */}
+
+
+ {table?.columns.map((col) => {
+ const isSelected = selectedColumns.includes(col.columnName);
+ const order = selectedColumns.indexOf(col.columnName) + 1;
+
+ return (
+
+
toggleColumn(col.columnName)}
+ />
+
+
+
+ {col.columnLabel}
+
+
+ {col.columnName}
+
+
+
+ {isSelected && (
+
+ {order}번째
+
+ )}
+
+ );
+ })}
+
+
+
+ {/* 그룹 순서 미리보기 */}
+ {selectedColumns.length > 0 && (
+
+
+ 그룹화 순서
+
+
+ {selectedColumns.map((colName, index) => {
+ const col = table?.columns.find(
+ (c) => c.columnName === colName
+ );
+ return (
+
+
+ {col?.columnLabel}
+
+ {index < selectedColumns.length - 1 && (
+
+ )}
+
+ );
+ })}
+
+
+ )}
+
+
+
+ onOpenChange(false)}
+ className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
+ >
+ 취소
+
+
+ 저장
+
+
+
+
+ );
+};
+
diff --git a/frontend/components/screen/table-options/TableOptionsToolbar.tsx b/frontend/components/screen/table-options/TableOptionsToolbar.tsx
new file mode 100644
index 00000000..20cbf299
--- /dev/null
+++ b/frontend/components/screen/table-options/TableOptionsToolbar.tsx
@@ -0,0 +1,126 @@
+import React, { useState } from "react";
+import { useTableOptions } from "@/contexts/TableOptionsContext";
+import { Button } from "@/components/ui/button";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Settings, Filter, Layers } from "lucide-react";
+import { ColumnVisibilityPanel } from "./ColumnVisibilityPanel";
+import { FilterPanel } from "./FilterPanel";
+import { GroupingPanel } from "./GroupingPanel";
+
+export const TableOptionsToolbar: React.FC = () => {
+ const { registeredTables, selectedTableId, setSelectedTableId } =
+ useTableOptions();
+
+ const [columnPanelOpen, setColumnPanelOpen] = useState(false);
+ const [filterPanelOpen, setFilterPanelOpen] = useState(false);
+ const [groupPanelOpen, setGroupPanelOpen] = useState(false);
+
+ const tableList = Array.from(registeredTables.values());
+ const selectedTable = selectedTableId
+ ? registeredTables.get(selectedTableId)
+ : null;
+
+ // 테이블이 없으면 표시하지 않음
+ if (tableList.length === 0) {
+ return null;
+ }
+
+ return (
+
+ {/* 테이블 선택 (2개 이상일 때만 표시) */}
+ {tableList.length > 1 && (
+
+
+
+
+
+ {tableList.map((table) => (
+
+ {table.label}
+
+ ))}
+
+
+ )}
+
+ {/* 테이블이 1개일 때는 이름만 표시 */}
+ {tableList.length === 1 && (
+
+ {tableList[0].label}
+
+ )}
+
+ {/* 컬럼 수 표시 */}
+
+ 전체 {selectedTable?.columns.length || 0}개
+
+
+
+
+ {/* 옵션 버튼들 */}
+
setColumnPanelOpen(true)}
+ className="h-8 text-xs sm:h-9 sm:text-sm"
+ disabled={!selectedTableId}
+ >
+
+ 테이블 옵션
+
+
+
setFilterPanelOpen(true)}
+ className="h-8 text-xs sm:h-9 sm:text-sm"
+ disabled={!selectedTableId}
+ >
+
+ 필터 설정
+
+
+
setGroupPanelOpen(true)}
+ className="h-8 text-xs sm:h-9 sm:text-sm"
+ disabled={!selectedTableId}
+ >
+
+ 그룹 설정
+
+
+ {/* 패널들 */}
+ {selectedTableId && (
+ <>
+
+
+
+ >
+ )}
+
+ );
+};
+
diff --git a/frontend/components/screen/widgets/FlowWidget.tsx b/frontend/components/screen/widgets/FlowWidget.tsx
index a6bda4cb..eaf1755d 100644
--- a/frontend/components/screen/widgets/FlowWidget.tsx
+++ b/frontend/components/screen/widgets/FlowWidget.tsx
@@ -39,6 +39,8 @@ import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
import { useAuth } from "@/hooks/useAuth";
+import { useTableOptions } from "@/contexts/TableOptionsContext";
+import { TableFilter, ColumnVisibility } from "@/types/table-options";
// 그룹화된 데이터 인터페이스
interface GroupedData {
@@ -65,6 +67,12 @@ export function FlowWidget({
}: FlowWidgetProps) {
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
const { user } = useAuth(); // 사용자 정보 가져오기
+ const { registerTable, unregisterTable } = useTableOptions(); // Context 훅
+
+ // TableOptions 상태
+ const [filters, setFilters] = useState([]);
+ const [grouping, setGrouping] = useState([]);
+ const [columnVisibility, setColumnVisibility] = useState([]);
// 숫자 포맷팅 함수
const formatValue = (value: any): string => {
@@ -301,6 +309,36 @@ export function FlowWidget({
toast.success("그룹이 해제되었습니다");
}, [groupSettingKey]);
+ // 테이블 등록 (선택된 스텝이 있을 때)
+ useEffect(() => {
+ if (!selectedStepId || !stepDataColumns || stepDataColumns.length === 0) {
+ return;
+ }
+
+ const tableId = `flow-widget-${component.id}-step-${selectedStepId}`;
+ const currentStep = steps.find((s) => s.id === selectedStepId);
+
+ registerTable({
+ tableId,
+ label: `${flowName || "플로우"} - ${currentStep?.name || "스텝"}`,
+ tableName: "flow_step_data",
+ columns: stepDataColumns.map((col) => ({
+ columnName: col,
+ columnLabel: columnLabels[col] || col,
+ inputType: "text",
+ visible: true,
+ width: 150,
+ sortable: true,
+ filterable: true,
+ })),
+ onFilterChange: setFilters,
+ onGroupChange: setGrouping,
+ onColumnVisibilityChange: setColumnVisibility,
+ });
+
+ return () => unregisterTable(tableId);
+ }, [selectedStepId, stepDataColumns, columnLabels, flowName, steps, component.id]);
+
// 🆕 데이터 그룹화
const groupedData = useMemo((): GroupedData[] => {
const dataToGroup = filteredData.length > 0 ? filteredData : stepData;
diff --git a/frontend/contexts/TableOptionsContext.tsx b/frontend/contexts/TableOptionsContext.tsx
new file mode 100644
index 00000000..769239b3
--- /dev/null
+++ b/frontend/contexts/TableOptionsContext.tsx
@@ -0,0 +1,107 @@
+import React, {
+ createContext,
+ useContext,
+ useState,
+ useCallback,
+ ReactNode,
+} from "react";
+import {
+ TableRegistration,
+ TableOptionsContextValue,
+} from "@/types/table-options";
+
+const TableOptionsContext = createContext(
+ undefined
+);
+
+export const TableOptionsProvider: React.FC<{ children: ReactNode }> = ({
+ children,
+}) => {
+ const [registeredTables, setRegisteredTables] = useState<
+ Map
+ >(new Map());
+ const [selectedTableId, setSelectedTableId] = useState(null);
+
+ /**
+ * 테이블 등록
+ */
+ const registerTable = useCallback((registration: TableRegistration) => {
+ setRegisteredTables((prev) => {
+ const newMap = new Map(prev);
+ newMap.set(registration.tableId, registration);
+
+ // 첫 번째 테이블이면 자동 선택
+ if (newMap.size === 1) {
+ setSelectedTableId(registration.tableId);
+ }
+
+ return newMap;
+ });
+
+ console.log(
+ `[TableOptions] 테이블 등록: ${registration.label} (${registration.tableId})`
+ );
+ }, []);
+
+ /**
+ * 테이블 등록 해제
+ */
+ const unregisterTable = useCallback(
+ (tableId: string) => {
+ setRegisteredTables((prev) => {
+ const newMap = new Map(prev);
+ const removed = newMap.delete(tableId);
+
+ if (removed) {
+ console.log(`[TableOptions] 테이블 해제: ${tableId}`);
+
+ // 선택된 테이블이 제거되면 첫 번째 테이블 선택
+ if (selectedTableId === tableId) {
+ const firstTableId = newMap.keys().next().value;
+ setSelectedTableId(firstTableId || null);
+ }
+ }
+
+ return newMap;
+ });
+ },
+ [selectedTableId]
+ );
+
+ /**
+ * 특정 테이블 조회
+ */
+ const getTable = useCallback(
+ (tableId: string) => {
+ return registeredTables.get(tableId);
+ },
+ [registeredTables]
+ );
+
+ return (
+
+ {children}
+
+ );
+};
+
+/**
+ * Context Hook
+ */
+export const useTableOptions = () => {
+ const context = useContext(TableOptionsContext);
+ if (!context) {
+ throw new Error("useTableOptions must be used within TableOptionsProvider");
+ }
+ return context;
+};
+
diff --git a/frontend/lib/registry/components/index.ts b/frontend/lib/registry/components/index.ts
index f2385b9b..adc86414 100644
--- a/frontend/lib/registry/components/index.ts
+++ b/frontend/lib/registry/components/index.ts
@@ -42,6 +42,7 @@ import "./repeater-field-group/RepeaterFieldGroupRenderer";
import "./flow-widget/FlowWidgetRenderer";
import "./numbering-rule/NumberingRuleRenderer";
import "./category-manager/CategoryManagerRenderer";
+import "./table-search-widget"; // 🆕 테이블 검색 필터 위젯
/**
* 컴포넌트 초기화 함수
diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx
index 60936930..483fc393 100644
--- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx
+++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx
@@ -12,6 +12,8 @@ import { useToast } from "@/hooks/use-toast";
import { tableTypeApi } from "@/lib/api/screen";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
+import { useTableOptions } from "@/contexts/TableOptionsContext";
+import { TableFilter, ColumnVisibility } from "@/types/table-options";
export interface SplitPanelLayoutComponentProps extends ComponentRendererProps {
// 추가 props
@@ -37,6 +39,15 @@ export const SplitPanelLayoutComponent: React.FC
const minLeftWidth = componentConfig.minLeftWidth || 200;
const minRightWidth = componentConfig.minRightWidth || 300;
+ // TableOptions Context
+ const { registerTable, unregisterTable } = useTableOptions();
+ const [leftFilters, setLeftFilters] = useState([]);
+ const [leftGrouping, setLeftGrouping] = useState([]);
+ const [leftColumnVisibility, setLeftColumnVisibility] = useState([]);
+ const [rightFilters, setRightFilters] = useState([]);
+ const [rightGrouping, setRightGrouping] = useState([]);
+ const [rightColumnVisibility, setRightColumnVisibility] = useState([]);
+
// 데이터 상태
const [leftData, setLeftData] = useState([]);
const [rightData, setRightData] = useState(null); // 조인 모드는 배열, 상세 모드는 객체
@@ -272,6 +283,68 @@ export const SplitPanelLayoutComponent: React.FC
[rightTableColumns],
);
+ // 좌측 테이블 등록 (Context에 등록)
+ useEffect(() => {
+ const leftTableName = componentConfig.leftPanel?.tableName;
+ if (!leftTableName || isDesignMode) return;
+
+ const leftTableId = `split-panel-left-${component.id}`;
+ const leftColumns = componentConfig.leftPanel?.displayColumns || [];
+
+ if (leftColumns.length > 0) {
+ registerTable({
+ tableId: leftTableId,
+ label: `${component.title || "분할 패널"} (좌측)`,
+ tableName: leftTableName,
+ columns: leftColumns.map((col: string) => ({
+ columnName: col,
+ columnLabel: leftColumnLabels[col] || col,
+ inputType: "text",
+ visible: true,
+ width: 150,
+ sortable: true,
+ filterable: true,
+ })),
+ onFilterChange: setLeftFilters,
+ onGroupChange: setLeftGrouping,
+ onColumnVisibilityChange: setLeftColumnVisibility,
+ });
+
+ return () => unregisterTable(leftTableId);
+ }
+ }, [component.id, componentConfig.leftPanel?.tableName, componentConfig.leftPanel?.displayColumns, leftColumnLabels, component.title, isDesignMode]);
+
+ // 우측 테이블 등록 (Context에 등록)
+ useEffect(() => {
+ const rightTableName = componentConfig.rightPanel?.tableName;
+ if (!rightTableName || isDesignMode) return;
+
+ const rightTableId = `split-panel-right-${component.id}`;
+ const rightColumns = rightTableColumns.map((col: any) => col.columnName || col.column_name).filter(Boolean);
+
+ if (rightColumns.length > 0) {
+ registerTable({
+ tableId: rightTableId,
+ label: `${component.title || "분할 패널"} (우측)`,
+ tableName: rightTableName,
+ columns: rightColumns.map((col: string) => ({
+ columnName: col,
+ columnLabel: rightColumnLabels[col] || col,
+ inputType: "text",
+ visible: true,
+ width: 150,
+ sortable: true,
+ filterable: true,
+ })),
+ onFilterChange: setRightFilters,
+ onGroupChange: setRightGrouping,
+ onColumnVisibilityChange: setRightColumnVisibility,
+ });
+
+ return () => unregisterTable(rightTableId);
+ }
+ }, [component.id, componentConfig.rightPanel?.tableName, rightTableColumns, rightColumnLabels, component.title, isDesignMode]);
+
// 좌측 테이블 컬럼 라벨 로드
useEffect(() => {
const loadLeftColumnLabels = async () => {
diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx
index 795c5bbb..a66e70ad 100644
--- a/frontend/lib/registry/components/table-list/TableListComponent.tsx
+++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx
@@ -45,6 +45,8 @@ import { AdvancedSearchFilters } from "@/components/screen/filters/AdvancedSearc
import { SingleTableWithSticky } from "./SingleTableWithSticky";
import { CardModeRenderer } from "./CardModeRenderer";
import { TableOptionsModal } from "@/components/common/TableOptionsModal";
+import { useTableOptions } from "@/contexts/TableOptionsContext";
+import { TableFilter, ColumnVisibility } from "@/types/table-options";
// ========================================
// 인터페이스
@@ -243,6 +245,12 @@ export const TableListComponent: React.FC = ({
// 상태 관리
// ========================================
+ // TableOptions Context
+ const { registerTable, unregisterTable } = useTableOptions();
+ const [filters, setFilters] = useState([]);
+ const [grouping, setGrouping] = useState([]);
+ const [columnVisibility, setColumnVisibility] = useState([]);
+
const [data, setData] = useState[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
@@ -288,6 +296,43 @@ export const TableListComponent: React.FC = ({
const [viewMode, setViewMode] = useState<"table" | "card" | "grouped-card">("table");
const [frozenColumns, setFrozenColumns] = useState([]);
+ // 테이블 등록 (Context에 등록)
+ const tableId = `table-list-${component.id}`;
+
+ useEffect(() => {
+ if (!tableConfig.selectedTable || !displayColumns || displayColumns.length === 0) {
+ return;
+ }
+
+ registerTable({
+ tableId,
+ label: tableLabel || tableConfig.selectedTable,
+ tableName: tableConfig.selectedTable,
+ columns: displayColumns.map((col) => ({
+ columnName: col.field,
+ columnLabel: columnLabels[col.field] || col.label || col.field,
+ inputType: columnMeta[col.field]?.inputType || "text",
+ visible: col.visible !== false,
+ width: columnWidths[col.field] || col.width || 150,
+ sortable: col.sortable !== false,
+ filterable: col.filterable !== false,
+ })),
+ onFilterChange: setFilters,
+ onGroupChange: setGrouping,
+ onColumnVisibilityChange: setColumnVisibility,
+ });
+
+ return () => unregisterTable(tableId);
+ }, [
+ component.id,
+ tableConfig.selectedTable,
+ displayColumns,
+ columnLabels,
+ columnMeta,
+ columnWidths,
+ tableLabel,
+ ]);
+
// 🆕 초기 로드 시 localStorage에서 컬럼 순서 불러오기
useEffect(() => {
if (!tableConfig.selectedTable || !userId) return;
diff --git a/frontend/lib/registry/components/table-search-widget/TableSearchWidget.tsx b/frontend/lib/registry/components/table-search-widget/TableSearchWidget.tsx
new file mode 100644
index 00000000..3fc7f94d
--- /dev/null
+++ b/frontend/lib/registry/components/table-search-widget/TableSearchWidget.tsx
@@ -0,0 +1,151 @@
+"use client";
+
+import React, { useState, useEffect } from "react";
+import { Button } from "@/components/ui/button";
+import { Settings, Filter, Layers } from "lucide-react";
+import { useTableOptions } from "@/contexts/TableOptionsContext";
+import { ColumnVisibilityPanel } from "@/components/screen/table-options/ColumnVisibilityPanel";
+import { FilterPanel } from "@/components/screen/table-options/FilterPanel";
+import { GroupingPanel } from "@/components/screen/table-options/GroupingPanel";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+
+interface TableSearchWidgetProps {
+ component: {
+ id: string;
+ title?: string;
+ style?: {
+ width?: string;
+ height?: string;
+ padding?: string;
+ backgroundColor?: string;
+ };
+ componentConfig?: {
+ autoSelectFirstTable?: boolean; // 첫 번째 테이블 자동 선택 여부
+ showTableSelector?: boolean; // 테이블 선택 드롭다운 표시 여부
+ };
+ };
+}
+
+export function TableSearchWidget({ component }: TableSearchWidgetProps) {
+ const { registeredTables, selectedTableId, setSelectedTableId } = useTableOptions();
+ const [columnVisibilityOpen, setColumnVisibilityOpen] = useState(false);
+ const [filterOpen, setFilterOpen] = useState(false);
+ const [groupingOpen, setGroupingOpen] = useState(false);
+
+ const autoSelectFirstTable = component.componentConfig?.autoSelectFirstTable ?? true;
+ const showTableSelector = component.componentConfig?.showTableSelector ?? true;
+
+ // Map을 배열로 변환
+ const tableList = Array.from(registeredTables.values());
+
+ // 첫 번째 테이블 자동 선택
+ useEffect(() => {
+ const tables = Array.from(registeredTables.values());
+ if (autoSelectFirstTable && tables.length > 0 && !selectedTableId) {
+ setSelectedTableId(tables[0].tableId);
+ }
+ }, [registeredTables, selectedTableId, autoSelectFirstTable, setSelectedTableId]);
+
+ const hasMultipleTables = tableList.length > 1;
+
+ return (
+
+ {/* 왼쪽: 제목 + 테이블 정보 */}
+
+ {/* 제목 */}
+ {component.title && (
+
+ {component.title}
+
+ )}
+
+ {/* 테이블 선택 드롭다운 (여러 테이블이 있고, showTableSelector가 true일 때만) */}
+ {showTableSelector && hasMultipleTables && (
+
+
+
+
+
+ {tableList.map((table) => (
+
+ {table.label}
+
+ ))}
+
+
+ )}
+
+ {/* 테이블이 하나만 있을 때는 라벨만 표시 */}
+ {!hasMultipleTables && tableList.length === 1 && (
+
+ {tableList[0].label}
+
+ )}
+
+ {/* 테이블이 없을 때 */}
+ {tableList.length === 0 && (
+
+ 화면에 테이블 컴포넌트를 추가하면 자동으로 감지됩니다
+
+ )}
+
+
+ {/* 오른쪽: 버튼들 */}
+
+ setColumnVisibilityOpen(true)}
+ disabled={!selectedTableId}
+ className="h-8 text-xs sm:h-9 sm:text-sm"
+ >
+
+ 테이블 옵션
+
+
+ setFilterOpen(true)}
+ disabled={!selectedTableId}
+ className="h-8 text-xs sm:h-9 sm:text-sm"
+ >
+
+ 필터 설정
+
+
+ setGroupingOpen(true)}
+ disabled={!selectedTableId}
+ className="h-8 text-xs sm:h-9 sm:text-sm"
+ >
+
+ 그룹 설정
+
+
+
+ {/* 패널들 */}
+
setColumnVisibilityOpen(false)}
+ />
+ setFilterOpen(false)} />
+ setGroupingOpen(false)} />
+
+ );
+}
+
diff --git a/frontend/lib/registry/components/table-search-widget/TableSearchWidgetConfigPanel.tsx b/frontend/lib/registry/components/table-search-widget/TableSearchWidgetConfigPanel.tsx
new file mode 100644
index 00000000..646fd3c4
--- /dev/null
+++ b/frontend/lib/registry/components/table-search-widget/TableSearchWidgetConfigPanel.tsx
@@ -0,0 +1,78 @@
+"use client";
+
+import React, { useState, useEffect } from "react";
+import { Label } from "@/components/ui/label";
+import { Checkbox } from "@/components/ui/checkbox";
+
+interface TableSearchWidgetConfigPanelProps {
+ component: any;
+ onUpdateProperty: (property: string, value: any) => void;
+}
+
+export function TableSearchWidgetConfigPanel({
+ component,
+ onUpdateProperty,
+}: TableSearchWidgetConfigPanelProps) {
+ const [localAutoSelect, setLocalAutoSelect] = useState(
+ component.componentConfig?.autoSelectFirstTable ?? true
+ );
+ const [localShowSelector, setLocalShowSelector] = useState(
+ component.componentConfig?.showTableSelector ?? true
+ );
+
+ useEffect(() => {
+ setLocalAutoSelect(component.componentConfig?.autoSelectFirstTable ?? true);
+ setLocalShowSelector(component.componentConfig?.showTableSelector ?? true);
+ }, [component.componentConfig]);
+
+ return (
+
+
+
검색 필터 위젯 설정
+
+ 이 위젯은 화면 내의 테이블들을 자동으로 감지하여 검색, 필터, 그룹 기능을 제공합니다.
+
+
+
+ {/* 첫 번째 테이블 자동 선택 */}
+
+ {
+ setLocalAutoSelect(checked as boolean);
+ onUpdateProperty("componentConfig.autoSelectFirstTable", checked);
+ }}
+ />
+
+ 첫 번째 테이블 자동 선택
+
+
+
+ {/* 테이블 선택 드롭다운 표시 */}
+
+ {
+ setLocalShowSelector(checked as boolean);
+ onUpdateProperty("componentConfig.showTableSelector", checked);
+ }}
+ />
+
+ 테이블 선택 드롭다운 표시 (여러 테이블이 있을 때)
+
+
+
+
+
참고사항:
+
+ 테이블 리스트, 분할 패널, 플로우 위젯이 자동 감지됩니다
+ 여러 테이블이 있으면 드롭다운에서 선택할 수 있습니다
+ 선택한 테이블의 컬럼 정보가 자동으로 로드됩니다
+
+
+
+ );
+}
+
diff --git a/frontend/lib/registry/components/table-search-widget/TableSearchWidgetRenderer.tsx b/frontend/lib/registry/components/table-search-widget/TableSearchWidgetRenderer.tsx
new file mode 100644
index 00000000..6fe47cc7
--- /dev/null
+++ b/frontend/lib/registry/components/table-search-widget/TableSearchWidgetRenderer.tsx
@@ -0,0 +1,9 @@
+import React from "react";
+import { TableSearchWidget } from "./TableSearchWidget";
+
+export class TableSearchWidgetRenderer {
+ static render(component: any) {
+ return ;
+ }
+}
+
diff --git a/frontend/lib/registry/components/table-search-widget/index.tsx b/frontend/lib/registry/components/table-search-widget/index.tsx
new file mode 100644
index 00000000..2ab3b882
--- /dev/null
+++ b/frontend/lib/registry/components/table-search-widget/index.tsx
@@ -0,0 +1,41 @@
+"use client";
+
+import { ComponentRegistry } from "../../ComponentRegistry";
+import { TableSearchWidget } from "./TableSearchWidget";
+import { TableSearchWidgetRenderer } from "./TableSearchWidgetRenderer";
+import { TableSearchWidgetConfigPanel } from "./TableSearchWidgetConfigPanel";
+
+// 검색 필터 위젯 등록
+ComponentRegistry.registerComponent({
+ id: "table-search-widget",
+ name: "검색 필터",
+ nameEng: "Table Search Widget",
+ category: "utility", // 유틸리티 컴포넌트로 분류
+ description: "화면 내 테이블을 자동 감지하여 검색, 필터, 그룹 기능을 제공하는 위젯",
+ icon: "Search",
+ tags: ["table", "search", "filter", "group", "search-widget"],
+ webType: "custom",
+ defaultSize: { width: 1920, height: 80 }, // 픽셀 단위: 전체 너비 × 80px 높이
+ component: TableSearchWidget,
+ defaultProps: {
+ title: "테이블 검색",
+ style: {
+ width: "100%",
+ height: "80px",
+ padding: "0.75rem",
+ },
+ componentConfig: {
+ autoSelectFirstTable: true,
+ showTableSelector: true,
+ },
+ },
+ renderer: TableSearchWidgetRenderer.render,
+ configPanel: TableSearchWidgetConfigPanel,
+ version: "1.0.0",
+ author: "WACE",
+});
+
+export { TableSearchWidget } from "./TableSearchWidget";
+export { TableSearchWidgetRenderer } from "./TableSearchWidgetRenderer";
+export { TableSearchWidgetConfigPanel } from "./TableSearchWidgetConfigPanel";
+
diff --git a/frontend/types/table-options.ts b/frontend/types/table-options.ts
new file mode 100644
index 00000000..e8727a44
--- /dev/null
+++ b/frontend/types/table-options.ts
@@ -0,0 +1,73 @@
+/**
+ * 테이블 옵션 관련 타입 정의
+ */
+
+/**
+ * 테이블 필터 조건
+ */
+export interface TableFilter {
+ columnName: string;
+ operator:
+ | "equals"
+ | "contains"
+ | "startsWith"
+ | "endsWith"
+ | "gt"
+ | "lt"
+ | "gte"
+ | "lte"
+ | "notEquals";
+ value: string | number | boolean;
+}
+
+/**
+ * 컬럼 표시 설정
+ */
+export interface ColumnVisibility {
+ columnName: string;
+ visible: boolean;
+ width?: number;
+ order?: number;
+ fixed?: boolean; // 좌측 고정 여부
+}
+
+/**
+ * 테이블 컬럼 정보
+ */
+export interface TableColumn {
+ columnName: string;
+ columnLabel: string;
+ inputType: string;
+ visible: boolean;
+ width: number;
+ sortable?: boolean;
+ filterable?: boolean;
+}
+
+/**
+ * 테이블 등록 정보
+ */
+export interface TableRegistration {
+ tableId: string; // 고유 ID (예: "table-list-123")
+ label: string; // 사용자에게 보이는 이름 (예: "품목 관리")
+ tableName: string; // 실제 DB 테이블명 (예: "item_info")
+ columns: TableColumn[];
+
+ // 콜백 함수들
+ onFilterChange: (filters: TableFilter[]) => void;
+ onGroupChange: (groups: string[]) => void;
+ onColumnVisibilityChange: (columns: ColumnVisibility[]) => void;
+}
+
+/**
+ * Context 값 타입
+ */
+export interface TableOptionsContextValue {
+ registeredTables: Map;
+ registerTable: (registration: TableRegistration) => void;
+ unregisterTable: (tableId: string) => void;
+ getTable: (tableId: string) => TableRegistration | undefined;
+ selectedTableId: string | null;
+ setSelectedTableId: (tableId: string | null) => void;
+}
+