버튼 자동정렬기능 구현

This commit is contained in:
kjs 2025-10-24 10:37:02 +09:00
parent d9088816a7
commit d22369a050
11 changed files with 1747 additions and 289 deletions

View File

@ -15,7 +15,7 @@ services:
DATABASE_URL: postgresql://postgres:ph0909!!@39.117.244.52:11132/plm DATABASE_URL: postgresql://postgres:ph0909!!@39.117.244.52:11132/plm
JWT_SECRET: ilshin-plm-super-secret-jwt-key-2024 JWT_SECRET: ilshin-plm-super-secret-jwt-key-2024
JWT_EXPIRES_IN: 24h JWT_EXPIRES_IN: 24h
CORS_ORIGIN: https://v1.vexplor.com CORS_ORIGIN: https://v1.vexplor.com,https://api.vexplor.com
CORS_CREDENTIALS: "true" CORS_CREDENTIALS: "true"
LOG_LEVEL: info LOG_LEVEL: info
ENCRYPTION_KEY: ilshin-plm-mail-encryption-key-32characters-2024-secure ENCRYPTION_KEY: ilshin-plm-mail-encryption-key-32characters-2024-secure

View File

@ -11,6 +11,10 @@ import { toast } from "sonner";
import { initializeComponents } from "@/lib/registry/components"; import { initializeComponents } from "@/lib/registry/components";
import { EditModal } from "@/components/screen/EditModal"; import { EditModal } from "@/components/screen/EditModal";
import { RealtimePreview } from "@/components/screen/RealtimePreviewDynamic"; import { RealtimePreview } from "@/components/screen/RealtimePreviewDynamic";
import { FlowButtonGroup } from "@/components/screen/widgets/FlowButtonGroup";
import { FlowVisibilityConfig } from "@/types/control-management";
import { findAllButtonGroups } from "@/lib/utils/flowButtonGroupUtils";
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
export default function ScreenViewPage() { export default function ScreenViewPage() {
const params = useParams(); const params = useParams();
@ -219,98 +223,246 @@ export default function ScreenViewPage() {
}} }}
> >
{/* 최상위 컴포넌트들 렌더링 */} {/* 최상위 컴포넌트들 렌더링 */}
{layout.components {(() => {
.filter((component) => !component.parentId) // 🆕 플로우 버튼 그룹 감지 및 처리
.map((component) => ( const topLevelComponents = layout.components.filter((component) => !component.parentId);
<RealtimePreview
key={component.id}
component={component}
isSelected={false}
isDesignMode={false}
onClick={() => {}}
screenId={screenId}
tableName={screen?.tableName}
selectedRowsData={selectedRowsData}
onSelectedRowsChange={(_, selectedData) => {
console.log("🔍 화면에서 선택된 행 데이터:", selectedData);
setSelectedRowsData(selectedData);
}}
flowSelectedData={flowSelectedData}
flowSelectedStepId={flowSelectedStepId}
onFlowSelectedDataChange={(selectedData: any[], stepId: number | null) => {
console.log("🔍 [page.tsx] 플로우 선택된 데이터 받음:", {
dataCount: selectedData.length,
selectedData,
stepId,
});
setFlowSelectedData(selectedData);
setFlowSelectedStepId(stepId);
console.log("🔍 [page.tsx] 상태 업데이트 완료");
}}
refreshKey={tableRefreshKey}
onRefresh={() => {
console.log("🔄 테이블 새로고침 요청됨");
setTableRefreshKey((prev) => prev + 1);
setSelectedRowsData([]); // 선택 해제
}}
flowRefreshKey={flowRefreshKey}
onFlowRefresh={() => {
console.log("🔄 플로우 새로고침 요청됨");
setFlowRefreshKey((prev) => prev + 1);
setFlowSelectedData([]); // 선택 해제
setFlowSelectedStepId(null);
}}
formData={formData}
onFormDataChange={(fieldName, value) => {
console.log("📝 폼 데이터 변경:", fieldName, "=", value);
setFormData((prev) => ({ ...prev, [fieldName]: value }));
}}
>
{/* 자식 컴포넌트들 */}
{(component.type === "group" || component.type === "container" || component.type === "area") &&
layout.components
.filter((child) => child.parentId === component.id)
.map((child) => {
// 자식 컴포넌트의 위치를 부모 기준 상대 좌표로 조정
const relativeChildComponent = {
...child,
position: {
x: child.position.x - component.position.x,
y: child.position.y - component.position.y,
z: child.position.z || 1,
},
};
return ( const buttonGroups: Record<string, any[]> = {};
<RealtimePreview const processedButtonIds = new Set<string>();
key={child.id}
component={relativeChildComponent} topLevelComponents.forEach((component) => {
isSelected={false} const isButton =
isDesignMode={false} component.type === "button" ||
onClick={() => {}} (component.type === "component" &&
screenId={screenId} ["button-primary", "button-secondary"].includes((component as any).componentType));
tableName={screen?.tableName}
selectedRowsData={selectedRowsData} if (isButton) {
onSelectedRowsChange={(_, selectedData) => { const flowConfig = (component as any).webTypeConfig?.flowVisibilityConfig as
console.log("🔍 화면에서 선택된 행 데이터 (자식):", selectedData); | FlowVisibilityConfig
setSelectedRowsData(selectedData); | undefined;
}}
refreshKey={tableRefreshKey} if (flowConfig?.enabled && flowConfig.layoutBehavior === "auto-compact" && flowConfig.groupId) {
onRefresh={() => { if (!buttonGroups[flowConfig.groupId]) {
console.log("🔄 테이블 새로고침 요청됨 (자식)"); buttonGroups[flowConfig.groupId] = [];
setTableRefreshKey((prev) => prev + 1); }
setSelectedRowsData([]); // 선택 해제 buttonGroups[flowConfig.groupId].push(component);
}} processedButtonIds.add(component.id);
formData={formData} }
onFormDataChange={(fieldName, value) => { }
console.log("📝 폼 데이터 변경 (자식):", fieldName, "=", value); });
setFormData((prev) => ({ ...prev, [fieldName]: value }));
}} const regularComponents = topLevelComponents.filter((c) => !processedButtonIds.has(c.id));
/>
); return (
})} <>
</RealtimePreview> {/* 일반 컴포넌트들 */}
))} {regularComponents.map((component) => (
<RealtimePreview
key={component.id}
component={component}
isSelected={false}
isDesignMode={false}
onClick={() => {}}
screenId={screenId}
tableName={screen?.tableName}
selectedRowsData={selectedRowsData}
onSelectedRowsChange={(_, selectedData) => {
console.log("🔍 화면에서 선택된 행 데이터:", selectedData);
setSelectedRowsData(selectedData);
}}
flowSelectedData={flowSelectedData}
flowSelectedStepId={flowSelectedStepId}
onFlowSelectedDataChange={(selectedData: any[], stepId: number | null) => {
console.log("🔍 [page.tsx] 플로우 선택된 데이터 받음:", {
dataCount: selectedData.length,
selectedData,
stepId,
});
setFlowSelectedData(selectedData);
setFlowSelectedStepId(stepId);
console.log("🔍 [page.tsx] 상태 업데이트 완료");
}}
refreshKey={tableRefreshKey}
onRefresh={() => {
console.log("🔄 테이블 새로고침 요청됨");
setTableRefreshKey((prev) => prev + 1);
setSelectedRowsData([]); // 선택 해제
}}
flowRefreshKey={flowRefreshKey}
onFlowRefresh={() => {
console.log("🔄 플로우 새로고침 요청됨");
setFlowRefreshKey((prev) => prev + 1);
setFlowSelectedData([]); // 선택 해제
setFlowSelectedStepId(null);
}}
formData={formData}
onFormDataChange={(fieldName, value) => {
console.log("📝 폼 데이터 변경:", fieldName, "=", value);
setFormData((prev) => ({ ...prev, [fieldName]: value }));
}}
>
{/* 자식 컴포넌트들 */}
{(component.type === "group" || component.type === "container" || component.type === "area") &&
layout.components
.filter((child) => child.parentId === component.id)
.map((child) => {
// 자식 컴포넌트의 위치를 부모 기준 상대 좌표로 조정
const relativeChildComponent = {
...child,
position: {
x: child.position.x - component.position.x,
y: child.position.y - component.position.y,
z: child.position.z || 1,
},
};
return (
<RealtimePreview
key={child.id}
component={relativeChildComponent}
isSelected={false}
isDesignMode={false}
onClick={() => {}}
screenId={screenId}
tableName={screen?.tableName}
selectedRowsData={selectedRowsData}
onSelectedRowsChange={(_, selectedData) => {
console.log("🔍 화면에서 선택된 행 데이터 (자식):", selectedData);
setSelectedRowsData(selectedData);
}}
refreshKey={tableRefreshKey}
onRefresh={() => {
console.log("🔄 테이블 새로고침 요청됨 (자식)");
setTableRefreshKey((prev) => prev + 1);
setSelectedRowsData([]); // 선택 해제
}}
formData={formData}
onFormDataChange={(fieldName, value) => {
console.log("📝 폼 데이터 변경 (자식):", fieldName, "=", value);
setFormData((prev) => ({ ...prev, [fieldName]: value }));
}}
/>
);
})}
</RealtimePreview>
))}
{/* 🆕 플로우 버튼 그룹들 */}
{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;
// 그룹의 위치는 모든 버튼 중 가장 왼쪽/위쪽 버튼의 위치 사용
const groupPosition = buttons.reduce(
(min, button) => ({
x: Math.min(min.x, button.position.x),
y: Math.min(min.y, button.position.y),
z: min.z,
}),
{ x: buttons[0].position.x, y: buttons[0].position.y, z: buttons[0].position.z || 2 },
);
// 그룹의 크기 계산: 버튼들의 실제 크기 + 간격을 기준으로 계산
const direction = groupConfig.groupDirection || "horizontal";
const gap = groupConfig.groupGap ?? 8;
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);
}
return (
<div
key={`flow-button-group-${groupId}`}
style={{
position: "absolute",
left: `${groupPosition.x}px`,
top: `${groupPosition.y}px`,
zIndex: groupPosition.z,
width: `${groupWidth}px`,
height: `${groupHeight}px`,
}}
>
<FlowButtonGroup
buttons={buttons}
groupConfig={groupConfig}
isDesignMode={false}
renderButton={(button) => {
const relativeButton = {
...button,
position: { x: 0, y: 0, z: button.position.z || 1 },
};
return (
<div
key={button.id}
style={{
position: "relative",
display: "inline-block",
width: button.size?.width || 100,
height: button.size?.height || 40,
}}
>
<div style={{ width: "100%", height: "100%" }}>
<DynamicComponentRenderer
component={relativeButton}
isDesignMode={false}
formData={formData}
onDataflowComplete={() => {}}
screenId={screenId}
tableName={screen?.tableName}
selectedRowsData={selectedRowsData}
onSelectedRowsChange={(_, selectedData) => {
setSelectedRowsData(selectedData);
}}
flowSelectedData={flowSelectedData}
flowSelectedStepId={flowSelectedStepId}
onFlowSelectedDataChange={(selectedData: any[], stepId: number | null) => {
setFlowSelectedData(selectedData);
setFlowSelectedStepId(stepId);
}}
refreshKey={tableRefreshKey}
onRefresh={() => {
setTableRefreshKey((prev) => prev + 1);
setSelectedRowsData([]);
}}
flowRefreshKey={flowRefreshKey}
onFlowRefresh={() => {
setFlowRefreshKey((prev) => prev + 1);
setFlowSelectedData([]);
setFlowSelectedStepId(null);
}}
onFormDataChange={(fieldName, value) => {
setFormData((prev) => ({ ...prev, [fieldName]: value }));
}}
/>
</div>
</div>
);
}}
/>
</div>
);
})}
</>
);
})()}
</div> </div>
) : ( ) : (
// 빈 화면일 때 // 빈 화면일 때

View File

@ -14,6 +14,9 @@ import { DynamicWebTypeRenderer } from "@/lib/registry";
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer"; import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
import { filterDOMProps } from "@/lib/utils/domPropsFilter"; import { filterDOMProps } from "@/lib/utils/domPropsFilter";
import { isFileComponent, isDataTableComponent, isButtonComponent } from "@/lib/utils/componentTypeUtils"; import { isFileComponent, isDataTableComponent, isButtonComponent } from "@/lib/utils/componentTypeUtils";
import { FlowButtonGroup } from "./widgets/FlowButtonGroup";
import { FlowVisibilityConfig } from "@/types/control-management";
import { findAllButtonGroups } from "@/lib/utils/flowButtonGroupUtils";
// 컴포넌트 렌더러들을 강제로 로드하여 레지스트리에 등록 // 컴포넌트 렌더러들을 강제로 로드하여 레지스트리에 등록
import "@/lib/registry/components/ButtonRenderer"; import "@/lib/registry/components/ButtonRenderer";

View File

@ -3,6 +3,7 @@
import { useState, useCallback, useEffect, useMemo, useRef } from "react"; import { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { Database, Cog } from "lucide-react"; import { Database, Cog } from "lucide-react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { import {
ScreenDefinition, ScreenDefinition,
ComponentData, ComponentData,
@ -49,6 +50,7 @@ import { safeMigrateLayout, needsMigration } from "@/lib/utils/widthToColumnSpan
import StyleEditor from "./StyleEditor"; import StyleEditor from "./StyleEditor";
import { RealtimePreview } from "./RealtimePreviewDynamic"; import { RealtimePreview } from "./RealtimePreviewDynamic";
import FloatingPanel from "./FloatingPanel"; import FloatingPanel from "./FloatingPanel";
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
import DesignerToolbar from "./DesignerToolbar"; import DesignerToolbar from "./DesignerToolbar";
import TablesPanel from "./panels/TablesPanel"; import TablesPanel from "./panels/TablesPanel";
import { TemplatesPanel, TemplateComponent } from "./panels/TemplatesPanel"; import { TemplatesPanel, TemplateComponent } from "./panels/TemplatesPanel";
@ -58,6 +60,16 @@ import DetailSettingsPanel from "./panels/DetailSettingsPanel";
import GridPanel from "./panels/GridPanel"; import GridPanel from "./panels/GridPanel";
import ResolutionPanel from "./panels/ResolutionPanel"; import ResolutionPanel from "./panels/ResolutionPanel";
import { usePanelState, PanelConfig } from "@/hooks/usePanelState"; import { usePanelState, PanelConfig } from "@/hooks/usePanelState";
import { FlowButtonGroup } from "./widgets/FlowButtonGroup";
import { FlowVisibilityConfig } from "@/types/control-management";
import {
areAllButtons,
generateGroupId,
groupButtons,
ungroupButtons,
findAllButtonGroups,
} from "@/lib/utils/flowButtonGroupUtils";
import { FlowButtonGroupDialog } from "./dialogs/FlowButtonGroupDialog";
// 새로운 통합 UI 컴포넌트 // 새로운 통합 UI 컴포넌트
import { LeftUnifiedToolbar, defaultToolbarButtons } from "./toolbar/LeftUnifiedToolbar"; import { LeftUnifiedToolbar, defaultToolbarButtons } from "./toolbar/LeftUnifiedToolbar";
@ -3467,6 +3479,127 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
toast.success(`${newComponents.length}개 컴포넌트가 붙여넣어졌습니다.`); toast.success(`${newComponents.length}개 컴포넌트가 붙여넣어졌습니다.`);
}, [clipboard, layout, saveToHistory]); }, [clipboard, layout, saveToHistory]);
// 🆕 플로우 버튼 그룹 생성 (다중 선택된 버튼들을 한 번에 그룹으로)
// 🆕 플로우 버튼 그룹 다이얼로그 상태
const [groupDialogOpen, setGroupDialogOpen] = useState(false);
const handleFlowButtonGroup = useCallback(() => {
const selectedComponents = layout.components.filter((comp) => groupState.selectedComponents.includes(comp.id));
// 선택된 컴포넌트가 없거나 1개 이하면 그룹화 불가
if (selectedComponents.length < 2) {
toast.error("그룹으로 묶을 버튼을 2개 이상 선택해주세요");
return;
}
// 모두 버튼인지 확인
if (!areAllButtons(selectedComponents)) {
toast.error("버튼 컴포넌트만 그룹으로 묶을 수 있습니다");
return;
}
// 🆕 다이얼로그 열기
setGroupDialogOpen(true);
}, [layout, groupState.selectedComponents]);
// 🆕 그룹 생성 확인 핸들러
const handleGroupConfirm = useCallback(
(settings: {
direction: "horizontal" | "vertical";
gap: number;
align: "start" | "center" | "end" | "space-between" | "space-around";
}) => {
const selectedComponents = layout.components.filter((comp) => groupState.selectedComponents.includes(comp.id));
// 고유한 그룹 ID 생성
const newGroupId = generateGroupId();
// 버튼들을 그룹으로 묶기 (설정 포함)
const groupedButtons = selectedComponents.map((button) => {
const currentConfig = (button as any).webTypeConfig?.flowVisibilityConfig || {};
return {
...button,
webTypeConfig: {
...(button as any).webTypeConfig,
flowVisibilityConfig: {
...currentConfig,
enabled: true,
layoutBehavior: "auto-compact",
groupId: newGroupId,
groupDirection: settings.direction,
groupGap: settings.gap,
groupAlign: settings.align,
},
},
};
});
// 레이아웃 업데이트
const updatedComponents = layout.components.map((comp) => {
const grouped = groupedButtons.find((gb) => gb.id === comp.id);
return grouped || comp;
});
const newLayout = {
...layout,
components: updatedComponents,
};
setLayout(newLayout);
saveToHistory(newLayout);
toast.success(`${selectedComponents.length}개의 버튼이 플로우 그룹으로 묶였습니다`, {
description: `그룹 ID: ${newGroupId} / ${settings.direction === "horizontal" ? "가로" : "세로"} / ${settings.gap}px 간격`,
});
console.log("✅ 플로우 버튼 그룹 생성 완료:", {
groupId: newGroupId,
buttonCount: selectedComponents.length,
buttons: selectedComponents.map((b) => b.id),
settings,
});
},
[layout, groupState.selectedComponents, saveToHistory],
);
// 🆕 플로우 버튼 그룹 해제
const handleFlowButtonUngroup = useCallback(() => {
const selectedComponents = layout.components.filter((comp) => groupState.selectedComponents.includes(comp.id));
if (selectedComponents.length === 0) {
toast.error("그룹 해제할 버튼을 선택해주세요");
return;
}
// 버튼이 아닌 것 필터링
const buttons = selectedComponents.filter((comp) => areAllButtons([comp]));
if (buttons.length === 0) {
toast.error("선택된 버튼 중 그룹화된 버튼이 없습니다");
return;
}
// 그룹 해제
const ungroupedButtons = ungroupButtons(buttons);
// 레이아웃 업데이트
const updatedComponents = layout.components.map((comp) => {
const ungrouped = ungroupedButtons.find((ub) => ub.id === comp.id);
return ungrouped || comp;
});
const newLayout = {
...layout,
components: updatedComponents,
};
setLayout(newLayout);
saveToHistory(newLayout);
toast.success(`${buttons.length}개의 버튼 그룹이 해제되었습니다`);
}, [layout, groupState.selectedComponents, saveToHistory]);
// 그룹 생성 (임시 비활성화) // 그룹 생성 (임시 비활성화)
const handleGroupCreate = useCallback( const handleGroupCreate = useCallback(
(componentIds: string[], title: string, style?: any) => { (componentIds: string[], title: string, style?: any) => {
@ -4181,6 +4314,86 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
<div className="bg-card text-foreground border-border pointer-events-none fixed right-6 bottom-6 z-50 rounded-lg border px-4 py-2 text-sm font-medium shadow-md"> <div className="bg-card text-foreground border-border pointer-events-none fixed right-6 bottom-6 z-50 rounded-lg border px-4 py-2 text-sm font-medium shadow-md">
🔍 {Math.round(zoomLevel * 100)}% 🔍 {Math.round(zoomLevel * 100)}%
</div> </div>
{/* 🆕 플로우 버튼 그룹 제어 (다중 선택 시 표시) */}
{groupState.selectedComponents.length >= 2 && (
<div className="bg-card border-border fixed right-6 bottom-20 z-50 rounded-lg border shadow-lg">
<div className="flex flex-col gap-2 p-3">
<div className="mb-1 flex items-center gap-2 text-xs text-gray-600">
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path>
<polyline points="3.29 7 12 12 20.71 7"></polyline>
<line x1="12" y1="22" x2="12" y2="12"></line>
</svg>
<span className="font-medium">{groupState.selectedComponents.length} </span>
</div>
<Button
size="sm"
variant="default"
onClick={handleFlowButtonGroup}
disabled={
!areAllButtons(layout.components.filter((c) => groupState.selectedComponents.includes(c.id)))
}
className="flex items-center gap-2 text-xs"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<line x1="9" y1="3" x2="9" y2="21"></line>
<line x1="15" y1="3" x2="15" y2="21"></line>
</svg>
</Button>
<Button
size="sm"
variant="outline"
onClick={handleFlowButtonUngroup}
className="flex items-center gap-2 text-xs"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="3" width="7" height="7"></rect>
<rect x="14" y="3" width="7" height="7"></rect>
<rect x="14" y="14" width="7" height="7"></rect>
<rect x="3" y="14" width="7" height="7"></rect>
</svg>
</Button>
{areAllButtons(layout.components.filter((c) => groupState.selectedComponents.includes(c.id))) ? (
<p className="mt-1 text-[10px] text-green-600"> </p>
) : (
<p className="mt-1 text-[10px] text-orange-600"> </p>
)}
</div>
</div>
)}
{/* 🔥 줌 적용 시 스크롤 영역 확보를 위한 래퍼 */} {/* 🔥 줌 적용 시 스크롤 영역 확보를 위한 래퍼 */}
<div <div
className="flex justify-center" className="flex justify-center"
@ -4244,208 +4457,387 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
))} ))}
{/* 컴포넌트들 */} {/* 컴포넌트들 */}
{layout.components {(() => {
.filter((component) => !component.parentId) // 최상위 컴포넌트만 렌더링 // 🆕 플로우 버튼 그룹 감지 및 처리
.map((component) => { const topLevelComponents = layout.components.filter((component) => !component.parentId);
const children =
component.type === "group"
? layout.components.filter((child) => child.parentId === component.id)
: [];
// 드래그 중 시각적 피드백 (다중 선택 지원) // auto-compact 모드의 버튼들을 그룹별로 묶기
const isDraggingThis = dragState.isDragging && dragState.draggedComponent?.id === component.id; const buttonGroups: Record<string, ComponentData[]> = {};
const isBeingDragged = const processedButtonIds = new Set<string>();
dragState.isDragging &&
dragState.draggedComponents.some((dragComp) => dragComp.id === component.id);
let displayComponent = component; topLevelComponents.forEach((component) => {
const isButton =
component.type === "button" ||
(component.type === "component" &&
["button-primary", "button-secondary"].includes((component as any).componentType));
if (isBeingDragged) { if (isButton) {
if (isDraggingThis) { const flowConfig = (component as any).webTypeConfig?.flowVisibilityConfig as
// 주 드래그 컴포넌트: 마우스 위치 기반으로 실시간 위치 업데이트 | FlowVisibilityConfig
displayComponent = { | undefined;
...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;
displayComponent = { if (flowConfig?.enabled && flowConfig.layoutBehavior === "auto-compact" && flowConfig.groupId) {
...component, if (!buttonGroups[flowConfig.groupId]) {
position: { buttonGroups[flowConfig.groupId] = [];
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, // 주 컴포넌트보다 약간 낮게
},
};
} }
buttonGroups[flowConfig.groupId].push(component);
processedButtonIds.add(component.id);
} }
} }
});
// 전역 파일 상태도 key에 포함하여 실시간 리렌더링 // 그룹에 속하지 않은 일반 컴포넌트들
const globalFileState = typeof window !== "undefined" ? (window as any).globalFileState || {} : {}; const regularComponents = topLevelComponents.filter((c) => !processedButtonIds.has(c.id));
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 ( return (
<RealtimePreview <>
key={`${component.id}-${fileStateKey}-${(component as any).lastFileUpdate || 0}-${forceRenderTrigger}`} {/* 일반 컴포넌트들 */}
component={displayComponent} {regularComponents.map((component) => {
isSelected={ const children =
selectedComponent?.id === component.id || groupState.selectedComponents.includes(component.id) component.type === "group"
} ? layout.components.filter((child) => child.parentId === component.id)
isDesignMode={true} // 편집 모드로 설정 : [];
onClick={(e) => handleComponentClick(component, e)}
onDoubleClick={(e) => handleComponentDoubleClick(component, e)}
onDragStart={(e) => startComponentDrag(component, e)}
onDragEnd={endDrag}
selectedScreen={selectedScreen}
// onZoneComponentDrop 제거
onZoneClick={handleZoneClick}
// 설정 변경 핸들러 (테이블 페이지 크기 등 설정을 상세설정에 반영)
onConfigChange={(config) => {
// console.log("📤 테이블 설정 변경을 상세설정에 반영:", config);
// 컴포넌트의 componentConfig 업데이트 // 드래그 중 시각적 피드백 (다중 선택 지원)
const updatedComponents = layout.components.map((comp) => { const isDraggingThis = dragState.isDragging && dragState.draggedComponent?.id === component.id;
if (comp.id === component.id) { const isBeingDragged =
return { dragState.isDragging &&
...comp, dragState.draggedComponents.some((dragComp) => dragComp.id === component.id);
componentConfig: {
...comp.componentConfig, let displayComponent = component;
...config,
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;
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, // 주 컴포넌트보다 약간 낮게
}, },
}; };
} }
return comp; }
}); }
const newLayout = { // 전역 파일 상태도 key에 포함하여 실시간 리렌더링
...layout, const globalFileState =
components: updatedComponents, 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}`;
setLayout(newLayout); return (
saveToHistory(newLayout); <RealtimePreview
key={`${component.id}-${fileStateKey}-${(component as any).lastFileUpdate || 0}-${forceRenderTrigger}`}
component={displayComponent}
isSelected={
selectedComponent?.id === component.id ||
groupState.selectedComponents.includes(component.id)
}
isDesignMode={true} // 편집 모드로 설정
onClick={(e) => handleComponentClick(component, e)}
onDoubleClick={(e) => handleComponentDoubleClick(component, e)}
onDragStart={(e) => startComponentDrag(component, e)}
onDragEnd={endDrag}
selectedScreen={selectedScreen}
// onZoneComponentDrop 제거
onZoneClick={handleZoneClick}
// 설정 변경 핸들러 (테이블 페이지 크기 등 설정을 상세설정에 반영)
onConfigChange={(config) => {
// console.log("📤 테이블 설정 변경을 상세설정에 반영:", config);
console.log("✅ 컴포넌트 설정 업데이트 완료:", { // 컴포넌트의 componentConfig 업데이트
componentId: component.id, const updatedComponents = layout.components.map((comp) => {
updatedConfig: config, if (comp.id === component.id) {
}); return {
}} ...comp,
> componentConfig: {
{/* 컨테이너, 그룹, 영역의 자식 컴포넌트들 렌더링 (레이아웃은 독립적으로 렌더링) */} ...comp.componentConfig,
{(component.type === "group" || component.type === "container" || component.type === "area") && ...config,
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 { }
// 다른 선택된 자식 컴포넌트들 return comp;
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 = { const newLayout = {
...child, ...layout,
position: { components: updatedComponents,
x: originalChildComponent.position.x + deltaX, };
y: originalChildComponent.position.y + deltaY,
z: originalChildComponent.position.z || 1, setLayout(newLayout);
} as Position, 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 =
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 (
<RealtimePreview
key={`${child.id}-${(child as any).uploadedFiles?.length || 0}-${JSON.stringify((child as any).uploadedFiles?.map((f: any) => 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과의 연동 필요
}}
/>
);
})}
</RealtimePreview>
);
})}
{/* 🆕 플로우 버튼 그룹들 */}
{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;
// 🔧 그룹의 위치는 모든 버튼 중 가장 왼쪽/위쪽 버튼의 위치 사용
const groupPosition = buttons.reduce(
(min, button) => ({
x: Math.min(min.x, button.position.x),
y: Math.min(min.y, button.position.y),
z: min.z,
}),
{ x: buttons[0].position.x, y: buttons[0].position.y, z: buttons[0].position.z || 2 },
);
// 🆕 그룹의 크기 계산: 버튼들의 실제 크기 + 간격을 기준으로 계산
const direction = groupConfig.groupDirection || "horizontal";
const gap = groupConfig.groupGap ?? 8;
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);
}
return (
<div
key={`flow-button-group-${groupId}`}
style={{
position: "absolute",
left: `${groupPosition.x}px`,
top: `${groupPosition.y}px`,
zIndex: groupPosition.z,
width: `${groupWidth}px`, // 🆕 명시적 너비
height: `${groupHeight}px`, // 🆕 명시적 높이
}}
>
<FlowButtonGroup
buttons={buttons}
groupConfig={groupConfig}
isDesignMode={true}
renderButton={(button, isVisible) => {
// 드래그 피드백
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: { style: {
...child.style, ...button.style,
opacity: 0.8, opacity: 0.8,
transform: "scale(1.02)",
transition: "none", transition: "none",
zIndex: 8888, zIndex: 50,
}, },
}; };
} }
} }
}
// 자식 컴포넌트의 위치를 부모 기준 상대 좌표로 조정 // 🔧 그룹 내부에서는 상대 위치 사용 (wrapper로 처리)
const relativeChildComponent = { const relativeButton = {
...displayChild, ...displayButton,
position: { position: {
x: displayChild.position.x - component.position.x, x: 0,
y: displayChild.position.y - component.position.y, y: 0,
z: displayChild.position.z || 1, z: displayButton.position.z || 1,
}, },
}; };
return ( return (
<RealtimePreview <div
key={`${child.id}-${(child as any).uploadedFiles?.length || 0}-${JSON.stringify((child as any).uploadedFiles?.map((f: any) => f.objid) || [])}`} key={button.id}
component={relativeChildComponent} style={{
isSelected={ position: "relative",
selectedComponent?.id === child.id || opacity: isVisible ? 1 : 0.5,
groupState.selectedComponents.includes(child.id) display: "inline-block",
} width: button.size?.width || 100,
isDesignMode={true} // 편집 모드로 설정 height: button.size?.height || 40,
onClick={(e) => handleComponentClick(child, e)} }}
onDoubleClick={(e) => handleComponentDoubleClick(child, e)} onClick={(e) => {
onDragStart={(e) => startComponentDrag(child, e)} e.stopPropagation();
onDragEnd={endDrag} handleComponentClick(button, e);
selectedScreen={selectedScreen} }}
// onZoneComponentDrop 제거 onDoubleClick={(e) => {
onZoneClick={handleZoneClick} e.stopPropagation();
// 설정 변경 핸들러 (자식 컴포넌트용) handleComponentDoubleClick(button, e);
onConfigChange={(config) => { }}
// console.log("📤 자식 컴포넌트 설정 변경을 상세설정에 알림:", config); className={
// TODO: 실제 구현은 DetailSettingsPanel과의 연동 필요 selectedComponent?.id === button.id ||
}} groupState.selectedComponents.includes(button.id)
/> ? "outline outline-2 outline-offset-2 outline-blue-500"
); : ""
})} }
</RealtimePreview> >
); {/* 그룹 내부에서는 DynamicComponentRenderer로 직접 렌더링 */}
})} <div style={{ width: "100%", height: "100%" }}>
<DynamicComponentRenderer
component={relativeButton}
isDesignMode={true}
formData={{}}
onDataflowComplete={() => {}}
/>
</div>
</div>
);
}}
/>
</div>
);
})}
</>
);
})()}
{/* 드래그 선택 영역 */} {/* 드래그 선택 영역 */}
{selectionDrag.isSelecting && ( {selectionDrag.isSelecting && (
@ -4495,6 +4887,13 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
</div> </div>
</div>{" "} </div>{" "}
{/* 메인 컨테이너 닫기 */} {/* 메인 컨테이너 닫기 */}
{/* 🆕 플로우 버튼 그룹 생성 다이얼로그 */}
<FlowButtonGroupDialog
open={groupDialogOpen}
onOpenChange={setGroupDialogOpen}
buttonCount={groupState.selectedComponents.length}
onConfirm={handleGroupConfirm}
/>
{/* 모달들 */} {/* 모달들 */}
{/* 메뉴 할당 모달 */} {/* 메뉴 할당 모달 */}
{showMenuAssignmentModal && selectedScreen && ( {showMenuAssignmentModal && selectedScreen && (

View File

@ -9,7 +9,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import { Workflow, Info, CheckCircle, XCircle, Loader2 } from "lucide-react"; import { Input } from "@/components/ui/input";
import { Workflow, Info, CheckCircle, XCircle, Loader2, ArrowRight, ArrowDown } from "lucide-react";
import { ComponentData } from "@/types/screen"; import { ComponentData } from "@/types/screen";
import { FlowVisibilityConfig } from "@/types/control-management"; import { FlowVisibilityConfig } from "@/types/control-management";
import { getFlowById } from "@/lib/api/flow"; import { getFlowById } from "@/lib/api/flow";
@ -57,6 +58,16 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
currentConfig?.layoutBehavior || "auto-compact" currentConfig?.layoutBehavior || "auto-compact"
); );
// 🆕 그룹 설정 (auto-compact 모드에서만 사용)
const [groupId, setGroupId] = useState<string>(currentConfig?.groupId || `group-${Date.now()}`);
const [groupDirection, setGroupDirection] = useState<"horizontal" | "vertical">(
currentConfig?.groupDirection || "horizontal"
);
const [groupGap, setGroupGap] = useState<number>(currentConfig?.groupGap ?? 8);
const [groupAlign, setGroupAlign] = useState<"start" | "center" | "end" | "space-between" | "space-around">(
currentConfig?.groupAlign || "start"
);
// 선택된 플로우의 스텝 목록 // 선택된 플로우의 스텝 목록
const [flowSteps, setFlowSteps] = useState<FlowStep[]>([]); const [flowSteps, setFlowSteps] = useState<FlowStep[]>([]);
const [flowInfo, setFlowInfo] = useState<FlowDefinition | null>(null); const [flowInfo, setFlowInfo] = useState<FlowDefinition | null>(null);
@ -136,8 +147,8 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
loadFlowSteps(); loadFlowSteps();
}, [selectedFlowComponentId, flowWidgets]); }, [selectedFlowComponentId, flowWidgets]);
// 설정 저장 // 🆕 설정 자동 저장 (즉시 적용) - 오버라이드 가능한 파라미터 지원
const handleSave = () => { const applyConfig = (overrides?: Partial<FlowVisibilityConfig>) => {
const config: FlowVisibilityConfig = { const config: FlowVisibilityConfig = {
enabled, enabled,
targetFlowComponentId: selectedFlowComponentId || "", targetFlowComponentId: selectedFlowComponentId || "",
@ -147,49 +158,79 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
visibleSteps: mode === "whitelist" ? visibleSteps : undefined, visibleSteps: mode === "whitelist" ? visibleSteps : undefined,
hiddenSteps: mode === "blacklist" ? hiddenSteps : undefined, hiddenSteps: mode === "blacklist" ? hiddenSteps : undefined,
layoutBehavior, layoutBehavior,
// 🆕 그룹 설정 (auto-compact 모드일 때만)
...(layoutBehavior === "auto-compact" && {
groupId,
groupDirection,
groupGap,
groupAlign,
}),
// 오버라이드 적용
...overrides,
}; };
console.log("💾 [FlowVisibilityConfig] 설정 자동 저장:", {
componentId: component.id,
config,
timestamp: new Date().toISOString(),
});
onUpdateProperty("webTypeConfig.flowVisibilityConfig", config); onUpdateProperty("webTypeConfig.flowVisibilityConfig", config);
toast.success("플로우 단계별 표시 설정이 저장되었습니다");
}; };
// 체크박스 토글 // 체크박스 토글
const toggleStep = (stepId: number) => { const toggleStep = (stepId: number) => {
if (mode === "whitelist") { if (mode === "whitelist") {
setVisibleSteps((prev) => const newSteps = visibleSteps.includes(stepId)
prev.includes(stepId) ? prev.filter((id) => id !== stepId) : [...prev, stepId] ? visibleSteps.filter((id) => id !== stepId)
); : [...visibleSteps, stepId];
setVisibleSteps(newSteps);
// 🆕 새 상태값을 직접 전달하여 즉시 저장
applyConfig({ visibleSteps: newSteps });
} else if (mode === "blacklist") { } else if (mode === "blacklist") {
setHiddenSteps((prev) => const newSteps = hiddenSteps.includes(stepId)
prev.includes(stepId) ? prev.filter((id) => id !== stepId) : [...prev, stepId] ? hiddenSteps.filter((id) => id !== stepId)
); : [...hiddenSteps, stepId];
setHiddenSteps(newSteps);
// 🆕 새 상태값을 직접 전달하여 즉시 저장
applyConfig({ hiddenSteps: newSteps });
} }
}; };
// 빠른 선택 // 빠른 선택
const selectAll = () => { const selectAll = () => {
if (mode === "whitelist") { if (mode === "whitelist") {
setVisibleSteps(flowSteps.map((s) => s.id)); const newSteps = flowSteps.map((s) => s.id);
setVisibleSteps(newSteps);
applyConfig({ visibleSteps: newSteps });
} else if (mode === "blacklist") { } else if (mode === "blacklist") {
setHiddenSteps([]); setHiddenSteps([]);
applyConfig({ hiddenSteps: [] });
} }
}; };
const selectNone = () => { const selectNone = () => {
if (mode === "whitelist") { if (mode === "whitelist") {
setVisibleSteps([]); setVisibleSteps([]);
applyConfig({ visibleSteps: [] });
} else if (mode === "blacklist") { } else if (mode === "blacklist") {
setHiddenSteps(flowSteps.map((s) => s.id)); const newSteps = flowSteps.map((s) => s.id);
setHiddenSteps(newSteps);
applyConfig({ hiddenSteps: newSteps });
} }
}; };
const invertSelection = () => { const invertSelection = () => {
if (mode === "whitelist") { if (mode === "whitelist") {
const allStepIds = flowSteps.map((s) => s.id); const allStepIds = flowSteps.map((s) => s.id);
setVisibleSteps(allStepIds.filter((id) => !visibleSteps.includes(id))); const newSteps = allStepIds.filter((id) => !visibleSteps.includes(id));
setVisibleSteps(newSteps);
applyConfig({ visibleSteps: newSteps });
} else if (mode === "blacklist") { } else if (mode === "blacklist") {
const allStepIds = flowSteps.map((s) => s.id); const allStepIds = flowSteps.map((s) => s.id);
setHiddenSteps(allStepIds.filter((id) => !hiddenSteps.includes(id))); const newSteps = allStepIds.filter((id) => !hiddenSteps.includes(id));
setHiddenSteps(newSteps);
applyConfig({ hiddenSteps: newSteps });
} }
}; };
@ -208,7 +249,14 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{/* 활성화 체크박스 */} {/* 활성화 체크박스 */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Checkbox id="flow-control-enabled" checked={enabled} onCheckedChange={(checked) => setEnabled(!!checked)} /> <Checkbox
id="flow-control-enabled"
checked={enabled}
onCheckedChange={(checked) => {
setEnabled(!!checked);
setTimeout(() => applyConfig(), 0);
}}
/>
<Label htmlFor="flow-control-enabled" className="text-sm font-medium"> <Label htmlFor="flow-control-enabled" className="text-sm font-medium">
</Label> </Label>
@ -219,7 +267,13 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
{/* 대상 플로우 선택 */} {/* 대상 플로우 선택 */}
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm font-medium"> </Label> <Label className="text-sm font-medium"> </Label>
<Select value={selectedFlowComponentId || ""} onValueChange={setSelectedFlowComponentId}> <Select
value={selectedFlowComponentId || ""}
onValueChange={(value) => {
setSelectedFlowComponentId(value);
setTimeout(() => applyConfig(), 0);
}}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm"> <SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="플로우 위젯 선택" /> <SelectValue placeholder="플로우 위젯 선택" />
</SelectTrigger> </SelectTrigger>
@ -243,7 +297,13 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
{/* 모드 선택 */} {/* 모드 선택 */}
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm font-medium"> </Label> <Label className="text-sm font-medium"> </Label>
<RadioGroup value={mode} onValueChange={(value: any) => setMode(value)}> <RadioGroup
value={mode}
onValueChange={(value: any) => {
setMode(value);
setTimeout(() => applyConfig(), 0);
}}
>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<RadioGroupItem value="whitelist" id="mode-whitelist" /> <RadioGroupItem value="whitelist" id="mode-whitelist" />
<Label htmlFor="mode-whitelist" className="text-sm font-normal"> <Label htmlFor="mode-whitelist" className="text-sm font-normal">
@ -319,7 +379,13 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
{/* 레이아웃 옵션 */} {/* 레이아웃 옵션 */}
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm font-medium"> </Label> <Label className="text-sm font-medium"> </Label>
<RadioGroup value={layoutBehavior} onValueChange={(value: any) => setLayoutBehavior(value)}> <RadioGroup
value={layoutBehavior}
onValueChange={(value: any) => {
setLayoutBehavior(value);
setTimeout(() => applyConfig(), 0);
}}
>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<RadioGroupItem value="preserve-position" id="layout-preserve" /> <RadioGroupItem value="preserve-position" id="layout-preserve" />
<Label htmlFor="layout-preserve" className="text-sm font-normal"> <Label htmlFor="layout-preserve" className="text-sm font-normal">
@ -335,6 +401,113 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
</RadioGroup> </RadioGroup>
</div> </div>
{/* 🆕 그룹 설정 (auto-compact 모드일 때만 표시) */}
{layoutBehavior === "auto-compact" && (
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4 space-y-4">
<div className="flex items-center gap-2">
<Badge variant="secondary" className="text-xs">
</Badge>
<p className="text-xs text-muted-foreground">
ID를
</p>
</div>
{/* 그룹 ID */}
<div className="space-y-2">
<Label htmlFor="group-id" className="text-sm font-medium">
ID
</Label>
<Input
id="group-id"
value={groupId}
onChange={(e) => setGroupId(e.target.value)}
placeholder="group-1"
className="h-8 text-xs sm:h-9 sm:text-sm"
/>
<p className="text-[10px] text-muted-foreground">
ID를
</p>
</div>
{/* 정렬 방향 */}
<div className="space-y-2">
<Label className="text-sm font-medium"> </Label>
<RadioGroup
value={groupDirection}
onValueChange={(value: any) => {
setGroupDirection(value);
setTimeout(() => applyConfig(), 0);
}}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="horizontal" id="direction-horizontal" />
<Label htmlFor="direction-horizontal" className="flex items-center gap-2 text-sm font-normal">
<ArrowRight className="h-4 w-4" />
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="vertical" id="direction-vertical" />
<Label htmlFor="direction-vertical" className="flex items-center gap-2 text-sm font-normal">
<ArrowDown className="h-4 w-4" />
</Label>
</div>
</RadioGroup>
</div>
{/* 버튼 간격 */}
<div className="space-y-2">
<Label htmlFor="group-gap" className="text-sm font-medium">
(px)
</Label>
<div className="flex items-center gap-2">
<Input
id="group-gap"
type="number"
min={0}
max={100}
value={groupGap}
onChange={(e) => {
setGroupGap(Number(e.target.value));
setTimeout(() => applyConfig(), 0);
}}
className="h-8 text-xs sm:h-9 sm:text-sm"
/>
<Badge variant="outline" className="text-xs">
{groupGap}px
</Badge>
</div>
</div>
{/* 정렬 방식 */}
<div className="space-y-2">
<Label htmlFor="group-align" className="text-sm font-medium">
</Label>
<Select
value={groupAlign}
onValueChange={(value: any) => {
setGroupAlign(value);
setTimeout(() => applyConfig(), 0);
}}
>
<SelectTrigger id="group-align" className="h-8 text-xs sm:h-9 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="start"> </SelectItem>
<SelectItem value="center"> </SelectItem>
<SelectItem value="end"> </SelectItem>
<SelectItem value="space-between"> </SelectItem>
<SelectItem value="space-around"> </SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
{/* 미리보기 */} {/* 미리보기 */}
<Alert> <Alert>
<Info className="h-4 w-4" /> <Info className="h-4 w-4" />
@ -374,10 +547,13 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
</AlertDescription> </AlertDescription>
</Alert> </Alert>
{/* 저장 버튼 */} {/* 🆕 자동 저장 안내 */}
<Button onClick={handleSave} className="w-full"> <Alert className="border-green-200 bg-green-50">
<CheckCircle className="h-4 w-4 text-green-600" />
</Button> <AlertDescription className="text-xs text-green-800">
. .
</AlertDescription>
</Alert>
</> </>
)} )}

View File

@ -0,0 +1,203 @@
"use client";
import React, { useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { ArrowRight, ArrowDown } from "lucide-react";
interface FlowButtonGroupDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
buttonCount: number;
onConfirm: (settings: {
direction: "horizontal" | "vertical";
gap: number;
align: "start" | "center" | "end" | "space-between" | "space-around";
}) => void;
}
export const FlowButtonGroupDialog: React.FC<FlowButtonGroupDialogProps> = ({
open,
onOpenChange,
buttonCount,
onConfirm,
}) => {
const [direction, setDirection] = useState<"horizontal" | "vertical">("horizontal");
const [gap, setGap] = useState<number>(8);
const [align, setAlign] = useState<"start" | "center" | "end" | "space-between" | "space-around">("start");
const handleConfirm = () => {
onConfirm({ direction, gap, align });
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
{buttonCount} . .
</DialogDescription>
</DialogHeader>
<div className="space-y-4 sm:space-y-6">
{/* 정렬 방향 */}
<div className="space-y-3">
<Label className="text-sm font-medium"> </Label>
<RadioGroup value={direction} onValueChange={(value: any) => setDirection(value)}>
<div className="flex items-center space-x-3">
<RadioGroupItem value="horizontal" id="direction-horizontal" />
<Label htmlFor="direction-horizontal" className="flex items-center gap-2 text-sm font-normal">
<ArrowRight className="h-4 w-4 text-blue-600" />
<span> </span>
<Badge variant="secondary" className="ml-2 text-xs">
</Badge>
</Label>
</div>
<div className="flex items-center space-x-3">
<RadioGroupItem value="vertical" id="direction-vertical" />
<Label htmlFor="direction-vertical" className="flex items-center gap-2 text-sm font-normal">
<ArrowDown className="h-4 w-4 text-blue-600" />
<span> </span>
<Badge variant="secondary" className="ml-2 text-xs">
</Badge>
</Label>
</div>
</RadioGroup>
</div>
{/* 버튼 간격 */}
<div className="space-y-3">
<Label htmlFor="gap" className="text-sm font-medium">
(px)
</Label>
<div className="flex items-center gap-3">
<Input
id="gap"
type="number"
min={0}
max={100}
value={gap}
onChange={(e) => setGap(Number(e.target.value))}
className="h-9 text-sm sm:h-10"
/>
<Badge variant="outline" className="text-xs">
{gap}px
</Badge>
</div>
<p className="text-[10px] text-muted-foreground sm:text-xs"> </p>
</div>
{/* 정렬 방식 */}
<div className="space-y-3">
<Label htmlFor="align" className="text-sm font-medium">
</Label>
<Select value={align} onValueChange={(value: any) => setAlign(value)}>
<SelectTrigger id="align" className="h-9 text-sm sm:h-10">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="start">
<div className="flex items-center gap-2">
<span> </span>
<Badge variant="secondary" className="text-xs">
{direction === "horizontal" ? "← 왼쪽" : "↑ 위"}
</Badge>
</div>
</SelectItem>
<SelectItem value="center">
<div className="flex items-center gap-2">
<span> </span>
<Badge variant="secondary" className="text-xs">
</Badge>
</div>
</SelectItem>
<SelectItem value="end">
<div className="flex items-center gap-2">
<span> </span>
<Badge variant="secondary" className="text-xs">
{direction === "horizontal" ? "→ 오른쪽" : "↓ 아래"}
</Badge>
</div>
</SelectItem>
<SelectItem value="space-between">
<div className="flex items-center gap-2">
<span> </span>
<Badge variant="secondary" className="text-xs">
</Badge>
</div>
</SelectItem>
<SelectItem value="space-around">
<div className="flex items-center gap-2">
<span> </span>
<Badge variant="secondary" className="text-xs">
</Badge>
</div>
</SelectItem>
</SelectContent>
</Select>
<p className="text-[10px] text-muted-foreground sm:text-xs">
</p>
</div>
{/* 미리보기 */}
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4">
<p className="mb-3 text-xs font-medium text-blue-900"> </p>
<div className="space-y-2 text-xs text-blue-700">
<div className="flex items-center gap-2">
{direction === "horizontal" ? <ArrowRight className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />}
<span>
{direction === "horizontal" ? "가로" : "세로"} {gap}px
</span>
</div>
<div className="flex items-center gap-2">
<span></span>
<span>
{align === "start" && "시작점"}
{align === "center" && "중앙"}
{align === "end" && "끝점"}
{align === "space-between" && "양 끝"}
{align === "space-around" && "균등"}
</span>
</div>
</div>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="h-9 flex-1 text-sm sm:h-10 sm:flex-none"
>
</Button>
<Button onClick={handleConfirm} className="h-9 flex-1 text-sm sm:h-10 sm:flex-none">
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,174 @@
"use client";
import React, { useMemo } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Separator } from "@/components/ui/separator";
import { Layers, Trash2, ArrowRight, ArrowDown, Info } from "lucide-react";
import { ComponentData } from "@/types/screen";
import { findAllButtonGroups, getButtonGroupInfo, ButtonGroupInfo } from "@/lib/utils/flowButtonGroupUtils";
interface FlowButtonGroupPanelProps {
components: ComponentData[];
onSelectGroup: (buttonIds: string[]) => void;
onDeleteGroup: (groupId: string) => void;
}
/**
* FlowButtonGroupPanel
*
*
* -
* - ( )
* -
*/
export const FlowButtonGroupPanel: React.FC<FlowButtonGroupPanelProps> = ({
components,
onSelectGroup,
onDeleteGroup,
}) => {
// 모든 버튼 그룹 찾기
const buttonGroups = useMemo(() => findAllButtonGroups(components), [components]);
// 그룹 정보 배열
const groupInfos = useMemo(() => {
return Object.entries(buttonGroups)
.map(([groupId, buttons]) => getButtonGroupInfo(groupId, buttons))
.filter((info): info is ButtonGroupInfo => info !== null);
}, [buttonGroups]);
// 그룹이 없을 때
if (groupInfos.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Layers className="h-5 w-5" />
</CardTitle>
<CardDescription className="text-xs"> </CardDescription>
</CardHeader>
<CardContent>
<Alert>
<Info className="h-4 w-4" />
<AlertDescription className="text-xs">
<p className="mb-2"> :</p>
<ol className="ml-4 list-decimal space-y-1 text-[11px]">
<li>2 (Shift + )</li>
<li> "플로우 그룹 생성" </li>
<li> </li>
</ol>
</AlertDescription>
</Alert>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Layers className="h-5 w-5" />
<Badge variant="secondary" className="ml-auto text-xs">
{groupInfos.length}
</Badge>
</CardTitle>
<CardDescription className="text-xs"> </CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{groupInfos.map((groupInfo, index) => (
<div key={groupInfo.groupId}>
<div className="rounded-lg border border-gray-200 bg-gray-50 p-3 space-y-2">
{/* 그룹 헤더 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs font-mono">
#{index + 1}
</Badge>
<span className="text-xs text-muted-foreground">
{groupInfo.buttonCount}
</span>
</div>
<div className="flex items-center gap-1">
<Button
size="sm"
variant="ghost"
onClick={() => onSelectGroup(groupInfo.buttons.map((b) => b.id))}
className="h-7 px-2 text-xs"
>
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => onDeleteGroup(groupInfo.groupId)}
className="h-7 px-2 text-xs text-red-600 hover:bg-red-50 hover:text-red-700"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
{/* 그룹 설정 정보 */}
<div className="flex flex-wrap items-center gap-2 text-[11px]">
<div className="flex items-center gap-1">
{groupInfo.direction === "horizontal" ? (
<ArrowRight className="h-3 w-3 text-blue-600" />
) : (
<ArrowDown className="h-3 w-3 text-blue-600" />
)}
<span className="text-gray-600">
{groupInfo.direction === "horizontal" ? "가로" : "세로"}
</span>
</div>
<span className="text-gray-400"></span>
<span className="text-gray-600"> {groupInfo.gap}px</span>
<span className="text-gray-400"></span>
<span className="text-gray-600">
{groupInfo.align === "start" && "시작점"}
{groupInfo.align === "center" && "중앙"}
{groupInfo.align === "end" && "끝점"}
{groupInfo.align === "space-between" && "양끝"}
{groupInfo.align === "space-around" && "균등배분"}
</span>
</div>
{/* 그룹 ID (디버깅용) */}
<details className="text-[10px]">
<summary className="cursor-pointer text-gray-500 hover:text-gray-700">
ID
</summary>
<code className="mt-1 block rounded bg-gray-200 px-2 py-1 text-[9px]">
{groupInfo.groupId}
</code>
</details>
{/* 버튼 목록 */}
<div className="mt-2 space-y-1">
{groupInfo.buttons.map((button) => (
<div
key={button.id}
className="flex items-center gap-2 rounded bg-white px-2 py-1.5 text-xs"
>
<div className="h-2 w-2 rounded-full bg-blue-500" />
<span className="flex-1 truncate font-medium">
{button.label || button.text || "버튼"}
</span>
<code className="text-[10px] text-gray-400">{button.id.slice(-8)}</code>
</div>
))}
</div>
</div>
{index < groupInfos.length - 1 && <Separator className="my-3" />}
</div>
))}
</CardContent>
</Card>
);
};

View File

@ -0,0 +1,167 @@
"use client";
import React, { useMemo } from "react";
import { ComponentData } from "@/types/screen";
import { FlowVisibilityConfig } from "@/types/control-management";
import { useCurrentFlowStep } from "@/stores/flowStepStore";
interface FlowButtonGroupProps {
/**
*
*/
buttons: ComponentData[];
/**
* ( )
*/
groupConfig: FlowVisibilityConfig;
/**
*
*/
renderButton: (button: ComponentData, isVisible: boolean) => React.ReactNode;
/**
*
*/
isDesignMode?: boolean;
}
/**
* FlowButtonGroup
*
* /, auto-compact
* Flexbox로 .
*
* **:**
* - groupId를 Flexbox
* - /
* -
* - , ,
*/
export const FlowButtonGroup: React.FC<FlowButtonGroupProps> = ({
buttons,
groupConfig,
renderButton,
isDesignMode = false,
}) => {
// 현재 플로우 단계
const currentStep = useCurrentFlowStep(groupConfig.targetFlowComponentId);
// 각 버튼의 표시 여부 계산
const buttonVisibility = useMemo(() => {
return buttons.map((button) => {
const config = (button as any).webTypeConfig?.flowVisibilityConfig as FlowVisibilityConfig | undefined;
// 플로우 제어 비활성화 시 항상 표시
if (!config?.enabled) {
return true;
}
// 플로우 단계가 선택되지 않은 경우
if (currentStep === null) {
// 화이트리스트 모드일 때는 단계 미선택 시 숨김
if (config.mode === "whitelist") {
return false;
}
return true;
}
const { mode, visibleSteps = [], hiddenSteps = [] } = config;
if (mode === "whitelist") {
return visibleSteps.includes(currentStep);
} else if (mode === "blacklist") {
return !hiddenSteps.includes(currentStep);
} else if (mode === "all") {
return true;
}
return true;
});
}, [buttons, currentStep]);
// 표시할 버튼 필터링
const visibleButtons = useMemo(() => {
return buttons.filter((_, index) => buttonVisibility[index]);
}, [buttons, buttonVisibility]);
// 그룹 스타일 계산
const groupStyle: React.CSSProperties = useMemo(() => {
const direction = groupConfig.groupDirection || "horizontal";
const gap = groupConfig.groupGap ?? 8;
const align = groupConfig.groupAlign || "start";
let justifyContent: string;
switch (align) {
case "start":
justifyContent = "flex-start";
break;
case "center":
justifyContent = "center";
break;
case "end":
justifyContent = "flex-end";
break;
case "space-between":
justifyContent = "space-between";
break;
case "space-around":
justifyContent = "space-around";
break;
default:
justifyContent = "flex-start";
}
return {
display: "flex",
flexDirection: direction === "vertical" ? "column" : "row",
gap: `${gap}px`,
justifyContent,
alignItems: "center",
flexWrap: "wrap", // 넘칠 경우 줄바꿈
width: "100%", // 🆕 전체 너비를 차지하도록 설정 (끝점/중앙 정렬을 위해 필수)
};
}, [groupConfig]);
// 디자인 모드에서는 모든 버튼 표시 (반투명 처리)
if (isDesignMode) {
return (
<div style={groupStyle} className="flow-button-group">
{buttons.map((button, index) => (
<div
key={button.id}
style={{
opacity: buttonVisibility[index] ? 1 : 0.3,
position: "relative",
}}
>
{renderButton(button, buttonVisibility[index])}
{!buttonVisibility[index] && (
<div
className="pointer-events-none absolute inset-0 flex items-center justify-center bg-gray-900/10"
style={{
border: "1px dashed #94a3b8",
}}
>
<span className="text-[10px] text-gray-500"></span>
</div>
)}
</div>
))}
</div>
);
}
// 실제 뷰 모드: 보이는 버튼만 렌더링 (auto-compact 동작)
return (
<div style={groupStyle} className="flow-button-group">
{visibleButtons.map((button) => (
<div key={button.id} style={{ position: "relative" }}>
{renderButton(button, true)}
</div>
))}
</div>
);
};

View File

@ -0,0 +1,159 @@
import { ComponentData } from "@/types/screen";
import { FlowVisibilityConfig } from "@/types/control-management";
/**
*
*/
export function areAllButtons(components: ComponentData[]): boolean {
return components.every((comp) => {
return (
comp.type === "button" ||
(comp.type === "component" && ["button-primary", "button-secondary"].includes((comp as any).componentType))
);
});
}
/**
*
*/
export function getFlowEnabledButtons(components: ComponentData[]): ComponentData[] {
return components.filter((comp) => {
const flowConfig = (comp as any).webTypeConfig?.flowVisibilityConfig as FlowVisibilityConfig | undefined;
return flowConfig?.enabled;
});
}
/**
* ID
*/
export function generateGroupId(): string {
return `flow-group-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
}
/**
*
*/
export function groupButtons(
buttons: ComponentData[],
groupId: string,
groupSettings?: {
direction?: "horizontal" | "vertical";
gap?: number;
align?: "start" | "center" | "end" | "space-between" | "space-around";
}
): ComponentData[] {
return buttons.map((button) => {
const currentConfig = (button as any).webTypeConfig?.flowVisibilityConfig as FlowVisibilityConfig | undefined;
// 플로우 제어가 비활성화되어 있으면 먼저 활성화
const updatedConfig: FlowVisibilityConfig = {
enabled: currentConfig?.enabled ?? true,
targetFlowComponentId: currentConfig?.targetFlowComponentId || "",
targetFlowId: currentConfig?.targetFlowId,
targetFlowName: currentConfig?.targetFlowName,
mode: currentConfig?.mode || "whitelist",
visibleSteps: currentConfig?.visibleSteps || [],
hiddenSteps: currentConfig?.hiddenSteps || [],
layoutBehavior: "auto-compact", // 그룹화 시 자동으로 auto-compact 모드
groupId,
groupDirection: groupSettings?.direction || currentConfig?.groupDirection || "horizontal",
groupGap: groupSettings?.gap ?? currentConfig?.groupGap ?? 8,
groupAlign: groupSettings?.align || currentConfig?.groupAlign || "start",
};
return {
...button,
webTypeConfig: {
...(button as any).webTypeConfig,
flowVisibilityConfig: updatedConfig,
},
};
});
}
/**
*
*/
export function ungroupButtons(buttons: ComponentData[]): ComponentData[] {
return buttons.map((button) => {
const currentConfig = (button as any).webTypeConfig?.flowVisibilityConfig as FlowVisibilityConfig | undefined;
if (!currentConfig) return button;
const updatedConfig: FlowVisibilityConfig = {
...currentConfig,
layoutBehavior: "preserve-position", // 그룹 해제 시 preserve-position 모드로
groupId: undefined,
groupDirection: undefined,
groupGap: undefined,
groupAlign: undefined,
};
return {
...button,
webTypeConfig: {
...(button as any).webTypeConfig,
flowVisibilityConfig: updatedConfig,
},
};
});
}
/**
*
*/
export function findAllButtonGroups(components: ComponentData[]): Record<string, ComponentData[]> {
const groups: Record<string, ComponentData[]> = {};
components.forEach((comp) => {
const isButton =
comp.type === "button" ||
(comp.type === "component" && ["button-primary", "button-secondary"].includes((comp as any).componentType));
if (isButton) {
const flowConfig = (comp as any).webTypeConfig?.flowVisibilityConfig as FlowVisibilityConfig | undefined;
if (flowConfig?.enabled && flowConfig.layoutBehavior === "auto-compact" && flowConfig.groupId) {
if (!groups[flowConfig.groupId]) {
groups[flowConfig.groupId] = [];
}
groups[flowConfig.groupId].push(comp);
}
}
});
return groups;
}
/**
*
*/
export interface ButtonGroupInfo {
groupId: string;
buttonCount: number;
buttons: ComponentData[];
direction: "horizontal" | "vertical";
gap: number;
align: "start" | "center" | "end" | "space-between" | "space-around";
targetFlowName?: string;
}
export function getButtonGroupInfo(groupId: string, buttons: ComponentData[]): ButtonGroupInfo | null {
if (buttons.length === 0) return null;
const firstButton = buttons[0];
const flowConfig = (firstButton as any).webTypeConfig?.flowVisibilityConfig as FlowVisibilityConfig | undefined;
if (!flowConfig) return null;
return {
groupId,
buttonCount: buttons.length,
buttons,
direction: flowConfig.groupDirection || "horizontal",
gap: flowConfig.groupGap ?? 8,
align: flowConfig.groupAlign || "start",
targetFlowName: flowConfig.targetFlowName,
};
}

View File

@ -70,6 +70,10 @@ export interface FlowVisibilityConfig {
visibleSteps?: number[]; visibleSteps?: number[];
hiddenSteps?: number[]; hiddenSteps?: number[];
layoutBehavior: "preserve-position" | "auto-compact"; layoutBehavior: "preserve-position" | "auto-compact";
groupId?: string;
groupDirection?: "horizontal" | "vertical";
groupGap?: number;
groupAlign?: "start" | "center" | "end" | "space-between" | "space-around";
} }
/** /**

View File

@ -337,9 +337,30 @@ export interface FlowVisibilityConfig {
/** /**
* *
* - preserve-position: 원래 (display: none, ) * - preserve-position: 원래 (display: none, )
* - auto-compact: (Flexbox, ) * - auto-compact: (Flexbox )
*/ */
layoutBehavior: "preserve-position" | "auto-compact"; layoutBehavior: "preserve-position" | "auto-compact";
/**
* ID (auto-compact )
* ID를 FlowButtonGroup으로
*/
groupId?: string;
/**
* (auto-compact )
*/
groupDirection?: "horizontal" | "vertical";
/**
* (px, auto-compact )
*/
groupGap?: number;
/**
* (auto-compact )
*/
groupAlign?: "start" | "center" | "end" | "space-between" | "space-around";
} }
// ===== 데이터 테이블 관련 ===== // ===== 데이터 테이블 관련 =====