버튼 자동정렬기능 구현
This commit is contained in:
@@ -15,7 +15,7 @@ services:
|
||||
DATABASE_URL: postgresql://postgres:ph0909!!@39.117.244.52:11132/plm
|
||||
JWT_SECRET: ilshin-plm-super-secret-jwt-key-2024
|
||||
JWT_EXPIRES_IN: 24h
|
||||
CORS_ORIGIN: https://v1.vexplor.com
|
||||
CORS_ORIGIN: https://v1.vexplor.com,https://api.vexplor.com
|
||||
CORS_CREDENTIALS: "true"
|
||||
LOG_LEVEL: info
|
||||
ENCRYPTION_KEY: ilshin-plm-mail-encryption-key-32characters-2024-secure
|
||||
|
||||
@@ -11,6 +11,10 @@ import { toast } from "sonner";
|
||||
import { initializeComponents } from "@/lib/registry/components";
|
||||
import { EditModal } from "@/components/screen/EditModal";
|
||||
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() {
|
||||
const params = useParams();
|
||||
@@ -219,98 +223,246 @@ export default function ScreenViewPage() {
|
||||
}}
|
||||
>
|
||||
{/* 최상위 컴포넌트들 렌더링 */}
|
||||
{layout.components
|
||||
.filter((component) => !component.parentId)
|
||||
.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,
|
||||
},
|
||||
};
|
||||
{(() => {
|
||||
// 🆕 플로우 버튼 그룹 감지 및 처리
|
||||
const topLevelComponents = layout.components.filter((component) => !component.parentId);
|
||||
|
||||
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>
|
||||
))}
|
||||
const buttonGroups: Record<string, any[]> = {};
|
||||
const processedButtonIds = new Set<string>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const regularComponents = topLevelComponents.filter((c) => !processedButtonIds.has(c.id));
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 일반 컴포넌트들 */}
|
||||
{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>
|
||||
) : (
|
||||
// 빈 화면일 때
|
||||
|
||||
@@ -14,6 +14,9 @@ import { DynamicWebTypeRenderer } from "@/lib/registry";
|
||||
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
|
||||
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||
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";
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { Database, Cog } from "lucide-react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ScreenDefinition,
|
||||
ComponentData,
|
||||
@@ -49,6 +50,7 @@ import { safeMigrateLayout, needsMigration } from "@/lib/utils/widthToColumnSpan
|
||||
import StyleEditor from "./StyleEditor";
|
||||
import { RealtimePreview } from "./RealtimePreviewDynamic";
|
||||
import FloatingPanel from "./FloatingPanel";
|
||||
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
|
||||
import DesignerToolbar from "./DesignerToolbar";
|
||||
import TablesPanel from "./panels/TablesPanel";
|
||||
import { TemplatesPanel, TemplateComponent } from "./panels/TemplatesPanel";
|
||||
@@ -58,6 +60,16 @@ import DetailSettingsPanel from "./panels/DetailSettingsPanel";
|
||||
import GridPanel from "./panels/GridPanel";
|
||||
import ResolutionPanel from "./panels/ResolutionPanel";
|
||||
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 컴포넌트
|
||||
import { LeftUnifiedToolbar, defaultToolbarButtons } from "./toolbar/LeftUnifiedToolbar";
|
||||
@@ -3467,6 +3479,127 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||
toast.success(`${newComponents.length}개 컴포넌트가 붙여넣어졌습니다.`);
|
||||
}, [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(
|
||||
(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">
|
||||
🔍 {Math.round(zoomLevel * 100)}%
|
||||
</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
|
||||
className="flex justify-center"
|
||||
@@ -4244,208 +4457,387 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||
))}
|
||||
|
||||
{/* 컴포넌트들 */}
|
||||
{layout.components
|
||||
.filter((component) => !component.parentId) // 최상위 컴포넌트만 렌더링
|
||||
.map((component) => {
|
||||
const children =
|
||||
component.type === "group"
|
||||
? layout.components.filter((child) => child.parentId === component.id)
|
||||
: [];
|
||||
{(() => {
|
||||
// 🆕 플로우 버튼 그룹 감지 및 처리
|
||||
const topLevelComponents = layout.components.filter((component) => !component.parentId);
|
||||
|
||||
// 드래그 중 시각적 피드백 (다중 선택 지원)
|
||||
const isDraggingThis = dragState.isDragging && dragState.draggedComponent?.id === component.id;
|
||||
const isBeingDragged =
|
||||
dragState.isDragging &&
|
||||
dragState.draggedComponents.some((dragComp) => dragComp.id === component.id);
|
||||
// auto-compact 모드의 버튼들을 그룹별로 묶기
|
||||
const buttonGroups: Record<string, ComponentData[]> = {};
|
||||
const processedButtonIds = new Set<string>();
|
||||
|
||||
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 (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 (isButton) {
|
||||
const flowConfig = (component as any).webTypeConfig?.flowVisibilityConfig as
|
||||
| FlowVisibilityConfig
|
||||
| undefined;
|
||||
|
||||
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, // 주 컴포넌트보다 약간 낮게
|
||||
},
|
||||
};
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 전역 파일 상태도 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}`;
|
||||
// 그룹에 속하지 않은 일반 컴포넌트들
|
||||
const regularComponents = topLevelComponents.filter((c) => !processedButtonIds.has(c.id));
|
||||
|
||||
return (
|
||||
<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);
|
||||
return (
|
||||
<>
|
||||
{/* 일반 컴포넌트들 */}
|
||||
{regularComponents.map((component) => {
|
||||
const children =
|
||||
component.type === "group"
|
||||
? layout.components.filter((child) => child.parentId === component.id)
|
||||
: [];
|
||||
|
||||
// 컴포넌트의 componentConfig 업데이트
|
||||
const updatedComponents = layout.components.map((comp) => {
|
||||
if (comp.id === component.id) {
|
||||
return {
|
||||
...comp,
|
||||
componentConfig: {
|
||||
...comp.componentConfig,
|
||||
...config,
|
||||
// 드래그 중 시각적 피드백 (다중 선택 지원)
|
||||
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: 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 = {
|
||||
...layout,
|
||||
components: updatedComponents,
|
||||
};
|
||||
// 전역 파일 상태도 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}`;
|
||||
|
||||
setLayout(newLayout);
|
||||
saveToHistory(newLayout);
|
||||
return (
|
||||
<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("✅ 컴포넌트 설정 업데이트 완료:", {
|
||||
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,
|
||||
// 컴포넌트의 componentConfig 업데이트
|
||||
const updatedComponents = layout.components.map((comp) => {
|
||||
if (comp.id === component.id) {
|
||||
return {
|
||||
...comp,
|
||||
componentConfig: {
|
||||
...comp.componentConfig,
|
||||
...config,
|
||||
},
|
||||
};
|
||||
} 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;
|
||||
}
|
||||
return comp;
|
||||
});
|
||||
|
||||
displayChild = {
|
||||
...child,
|
||||
position: {
|
||||
x: originalChildComponent.position.x + deltaX,
|
||||
y: originalChildComponent.position.y + deltaY,
|
||||
z: originalChildComponent.position.z || 1,
|
||||
} as Position,
|
||||
const newLayout = {
|
||||
...layout,
|
||||
components: updatedComponents,
|
||||
};
|
||||
|
||||
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 =
|
||||
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: {
|
||||
...child.style,
|
||||
...button.style,
|
||||
opacity: 0.8,
|
||||
transform: "scale(1.02)",
|
||||
transition: "none",
|
||||
zIndex: 8888,
|
||||
zIndex: 50,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 자식 컴포넌트의 위치를 부모 기준 상대 좌표로 조정
|
||||
const relativeChildComponent = {
|
||||
...displayChild,
|
||||
position: {
|
||||
x: displayChild.position.x - component.position.x,
|
||||
y: displayChild.position.y - component.position.y,
|
||||
z: displayChild.position.z || 1,
|
||||
},
|
||||
};
|
||||
// 🔧 그룹 내부에서는 상대 위치 사용 (wrapper로 처리)
|
||||
const relativeButton = {
|
||||
...displayButton,
|
||||
position: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: displayButton.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>
|
||||
);
|
||||
})}
|
||||
return (
|
||||
<div
|
||||
key={button.id}
|
||||
style={{
|
||||
position: "relative",
|
||||
opacity: isVisible ? 1 : 0.5,
|
||||
display: "inline-block",
|
||||
width: button.size?.width || 100,
|
||||
height: button.size?.height || 40,
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleComponentClick(button, e);
|
||||
}}
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleComponentDoubleClick(button, e);
|
||||
}}
|
||||
className={
|
||||
selectedComponent?.id === button.id ||
|
||||
groupState.selectedComponents.includes(button.id)
|
||||
? "outline outline-2 outline-offset-2 outline-blue-500"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{/* 그룹 내부에서는 DynamicComponentRenderer로 직접 렌더링 */}
|
||||
<div style={{ width: "100%", height: "100%" }}>
|
||||
<DynamicComponentRenderer
|
||||
component={relativeButton}
|
||||
isDesignMode={true}
|
||||
formData={{}}
|
||||
onDataflowComplete={() => {}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* 드래그 선택 영역 */}
|
||||
{selectionDrag.isSelecting && (
|
||||
@@ -4495,6 +4887,13 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||
</div>
|
||||
</div>{" "}
|
||||
{/* 메인 컨테이너 닫기 */}
|
||||
{/* 🆕 플로우 버튼 그룹 생성 다이얼로그 */}
|
||||
<FlowButtonGroupDialog
|
||||
open={groupDialogOpen}
|
||||
onOpenChange={setGroupDialogOpen}
|
||||
buttonCount={groupState.selectedComponents.length}
|
||||
onConfirm={handleGroupConfirm}
|
||||
/>
|
||||
{/* 모달들 */}
|
||||
{/* 메뉴 할당 모달 */}
|
||||
{showMenuAssignmentModal && selectedScreen && (
|
||||
|
||||
@@ -9,7 +9,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
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 { FlowVisibilityConfig } from "@/types/control-management";
|
||||
import { getFlowById } from "@/lib/api/flow";
|
||||
@@ -57,6 +58,16 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
|
||||
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 [flowInfo, setFlowInfo] = useState<FlowDefinition | null>(null);
|
||||
@@ -136,8 +147,8 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
|
||||
loadFlowSteps();
|
||||
}, [selectedFlowComponentId, flowWidgets]);
|
||||
|
||||
// 설정 저장
|
||||
const handleSave = () => {
|
||||
// 🆕 설정 자동 저장 (즉시 적용) - 오버라이드 가능한 파라미터 지원
|
||||
const applyConfig = (overrides?: Partial<FlowVisibilityConfig>) => {
|
||||
const config: FlowVisibilityConfig = {
|
||||
enabled,
|
||||
targetFlowComponentId: selectedFlowComponentId || "",
|
||||
@@ -147,49 +158,79 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
|
||||
visibleSteps: mode === "whitelist" ? visibleSteps : undefined,
|
||||
hiddenSteps: mode === "blacklist" ? hiddenSteps : undefined,
|
||||
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);
|
||||
toast.success("플로우 단계별 표시 설정이 저장되었습니다");
|
||||
};
|
||||
|
||||
// 체크박스 토글
|
||||
const toggleStep = (stepId: number) => {
|
||||
if (mode === "whitelist") {
|
||||
setVisibleSteps((prev) =>
|
||||
prev.includes(stepId) ? prev.filter((id) => id !== stepId) : [...prev, stepId]
|
||||
);
|
||||
const newSteps = visibleSteps.includes(stepId)
|
||||
? visibleSteps.filter((id) => id !== stepId)
|
||||
: [...visibleSteps, stepId];
|
||||
setVisibleSteps(newSteps);
|
||||
// 🆕 새 상태값을 직접 전달하여 즉시 저장
|
||||
applyConfig({ visibleSteps: newSteps });
|
||||
} else if (mode === "blacklist") {
|
||||
setHiddenSteps((prev) =>
|
||||
prev.includes(stepId) ? prev.filter((id) => id !== stepId) : [...prev, stepId]
|
||||
);
|
||||
const newSteps = hiddenSteps.includes(stepId)
|
||||
? hiddenSteps.filter((id) => id !== stepId)
|
||||
: [...hiddenSteps, stepId];
|
||||
setHiddenSteps(newSteps);
|
||||
// 🆕 새 상태값을 직접 전달하여 즉시 저장
|
||||
applyConfig({ hiddenSteps: newSteps });
|
||||
}
|
||||
};
|
||||
|
||||
// 빠른 선택
|
||||
const selectAll = () => {
|
||||
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") {
|
||||
setHiddenSteps([]);
|
||||
applyConfig({ hiddenSteps: [] });
|
||||
}
|
||||
};
|
||||
|
||||
const selectNone = () => {
|
||||
if (mode === "whitelist") {
|
||||
setVisibleSteps([]);
|
||||
applyConfig({ visibleSteps: [] });
|
||||
} else if (mode === "blacklist") {
|
||||
setHiddenSteps(flowSteps.map((s) => s.id));
|
||||
const newSteps = flowSteps.map((s) => s.id);
|
||||
setHiddenSteps(newSteps);
|
||||
applyConfig({ hiddenSteps: newSteps });
|
||||
}
|
||||
};
|
||||
|
||||
const invertSelection = () => {
|
||||
if (mode === "whitelist") {
|
||||
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") {
|
||||
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">
|
||||
{/* 활성화 체크박스 */}
|
||||
<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>
|
||||
@@ -219,7 +267,13 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
|
||||
{/* 대상 플로우 선택 */}
|
||||
<div className="space-y-2">
|
||||
<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">
|
||||
<SelectValue placeholder="플로우 위젯 선택" />
|
||||
</SelectTrigger>
|
||||
@@ -243,7 +297,13 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
|
||||
{/* 모드 선택 */}
|
||||
<div className="space-y-2">
|
||||
<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">
|
||||
<RadioGroupItem value="whitelist" id="mode-whitelist" />
|
||||
<Label htmlFor="mode-whitelist" className="text-sm font-normal">
|
||||
@@ -319,7 +379,13 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
|
||||
{/* 레이아웃 옵션 */}
|
||||
<div className="space-y-2">
|
||||
<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">
|
||||
<RadioGroupItem value="preserve-position" id="layout-preserve" />
|
||||
<Label htmlFor="layout-preserve" className="text-sm font-normal">
|
||||
@@ -335,6 +401,113 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
|
||||
</RadioGroup>
|
||||
</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>
|
||||
<Info className="h-4 w-4" />
|
||||
@@ -374,10 +547,13 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{/* 저장 버튼 */}
|
||||
<Button onClick={handleSave} className="w-full">
|
||||
설정 저장
|
||||
</Button>
|
||||
{/* 🆕 자동 저장 안내 */}
|
||||
<Alert className="border-green-200 bg-green-50">
|
||||
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||
<AlertDescription className="text-xs text-green-800">
|
||||
설정이 자동으로 저장됩니다. 화면 저장 시 함께 적용됩니다.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
203
frontend/components/screen/dialogs/FlowButtonGroupDialog.tsx
Normal file
203
frontend/components/screen/dialogs/FlowButtonGroupDialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
174
frontend/components/screen/panels/FlowButtonGroupPanel.tsx
Normal file
174
frontend/components/screen/panels/FlowButtonGroupPanel.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
167
frontend/components/screen/widgets/FlowButtonGroup.tsx
Normal file
167
frontend/components/screen/widgets/FlowButtonGroup.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
159
frontend/lib/utils/flowButtonGroupUtils.ts
Normal file
159
frontend/lib/utils/flowButtonGroupUtils.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -70,6 +70,10 @@ export interface FlowVisibilityConfig {
|
||||
visibleSteps?: number[];
|
||||
hiddenSteps?: number[];
|
||||
layoutBehavior: "preserve-position" | "auto-compact";
|
||||
groupId?: string;
|
||||
groupDirection?: "horizontal" | "vertical";
|
||||
groupGap?: number;
|
||||
groupAlign?: "start" | "center" | "end" | "space-between" | "space-around";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -337,9 +337,30 @@ export interface FlowVisibilityConfig {
|
||||
/**
|
||||
* 레이아웃 동작 방식
|
||||
* - preserve-position: 원래 위치 유지 (display: none, 빈 공간 유지)
|
||||
* - auto-compact: 빈 공간 자동 제거 (Flexbox, 렌더링하지 않음)
|
||||
* - auto-compact: 빈 공간 자동 제거 (Flexbox 그룹으로 자동 정렬)
|
||||
*/
|
||||
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";
|
||||
}
|
||||
|
||||
// ===== 데이터 테이블 관련 =====
|
||||
|
||||
Reference in New Issue
Block a user