버튼 자동정렬기능 구현

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
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

View File

@ -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,9 +223,40 @@ export default function ScreenViewPage() {
}}
>
{/* 최상위 컴포넌트들 렌더링 */}
{layout.components
.filter((component) => !component.parentId)
.map((component) => (
{(() => {
// 🆕 플로우 버튼 그룹 감지 및 처리
const topLevelComponents = layout.components.filter((component) => !component.parentId);
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}
@ -311,6 +346,123 @@ export default function ScreenViewPage() {
})}
</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>
) : (
// 빈 화면일 때

View File

@ -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";

View File

@ -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,9 +4457,42 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
))}
{/* 컴포넌트들 */}
{layout.components
.filter((component) => !component.parentId) // 최상위 컴포넌트만 렌더링
.map((component) => {
{(() => {
// 🆕 플로우 버튼 그룹 감지 및 처리
const topLevelComponents = layout.components.filter((component) => !component.parentId);
// auto-compact 모드의 버튼들을 그룹별로 묶기
const buttonGroups: Record<string, ComponentData[]> = {};
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) => {
const children =
component.type === "group"
? layout.components.filter((child) => child.parentId === component.id)
@ -4302,7 +4548,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
}
// 전역 파일 상태도 key에 포함하여 실시간 리렌더링
const globalFileState = typeof window !== "undefined" ? (window as any).globalFileState || {} : {};
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}`;
@ -4312,7 +4559,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
key={`${component.id}-${fileStateKey}-${(component as any).lastFileUpdate || 0}-${forceRenderTrigger}`}
component={displayComponent}
isSelected={
selectedComponent?.id === component.id || groupState.selectedComponents.includes(component.id)
selectedComponent?.id === component.id ||
groupState.selectedComponents.includes(component.id)
}
isDesignMode={true} // 편집 모드로 설정
onClick={(e) => handleComponentClick(component, e)}
@ -4355,7 +4603,9 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
}}
>
{/* 컨테이너, 그룹, 영역의 자식 컴포넌트들 렌더링 (레이아웃은 독립적으로 렌더링) */}
{(component.type === "group" || component.type === "container" || component.type === "area") &&
{(component.type === "group" ||
component.type === "container" ||
component.type === "area") &&
layout.components
.filter((child) => child.parentId === component.id)
.map((child) => {
@ -4447,6 +4697,148 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
);
})}
{/* 🆕 플로우 버튼 그룹들 */}
{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: {
...button.style,
opacity: 0.8,
transform: "scale(1.02)",
transition: "none",
zIndex: 50,
},
};
}
}
// 🔧 그룹 내부에서는 상대 위치 사용 (wrapper로 처리)
const relativeButton = {
...displayButton,
position: {
x: 0,
y: 0,
z: displayButton.position.z || 1,
},
};
return (
<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 && (
<div
@ -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 && (

View File

@ -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>
</>
)}

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[];
hiddenSteps?: number[];
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, )
* - 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";
}
// ===== 데이터 테이블 관련 =====