메뉴들 플로팅 패널로 구현

This commit is contained in:
kjs 2025-09-02 16:18:38 +09:00
parent c3213b8a85
commit 9af3cdea01
13 changed files with 5704 additions and 1953 deletions

View File

@ -3,7 +3,7 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Plus, ArrowLeft, ArrowRight, CheckCircle, Circle } from "lucide-react";
import { Plus, ArrowLeft, ArrowRight, Circle } from "lucide-react";
import ScreenList from "@/components/screen/ScreenList";
import ScreenDesigner from "@/components/screen/ScreenDesigner";
import TemplateManager from "@/components/screen/TemplateManager";
@ -62,69 +62,11 @@ export default function ScreenManagementPage() {
}
};
// 단계별 진행 상태 확인
const isStepCompleted = (step: Step) => {
return stepHistory.includes(step);
};
// 현재 단계가 마지막 단계인지 확인
const isLastStep = currentStep === "template";
return (
<div className="flex h-full w-full flex-col">
{/* 페이지 헤더 */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900"> </h1>
<p className="mt-2 text-gray-600"> </p>
</div>
<div className="text-sm text-gray-500">{stepConfig[currentStep].description}</div>
</div>
{/* 단계별 진행 표시 */}
<div className="border-b bg-white p-4">
<div className="flex items-center justify-between">
{Object.entries(stepConfig).map(([step, config], index) => (
<div key={step} className="flex items-center">
<div className="flex flex-col items-center">
<button
onClick={() => goToStep(step as Step)}
className={`flex h-12 w-12 items-center justify-center rounded-full border-2 transition-all ${
currentStep === step
? "border-blue-600 bg-blue-600 text-white"
: isStepCompleted(step as Step)
? "border-green-500 bg-green-500 text-white"
: "border-gray-300 bg-white text-gray-400"
} ${isStepCompleted(step as Step) ? "cursor-pointer hover:bg-green-600" : ""}`}
>
{isStepCompleted(step as Step) && currentStep !== step ? (
<CheckCircle className="h-6 w-6" />
) : (
<span className="text-lg">{config.icon}</span>
)}
</button>
<div className="mt-2 text-center">
<div
className={`text-sm font-medium ${
currentStep === step
? "text-blue-600"
: isStepCompleted(step as Step)
? "text-green-600"
: "text-gray-500"
}`}
>
{config.title}
</div>
</div>
</div>
{index < Object.keys(stepConfig).length - 1 && (
<div className={`mx-4 h-0.5 w-16 ${isStepCompleted(step as Step) ? "bg-green-500" : "bg-gray-300"}`} />
)}
</div>
))}
</div>
</div>
{/* 단계별 내용 */}
<div className="flex-1 overflow-hidden">
{/* 화면 목록 단계 */}

View File

@ -0,0 +1,158 @@
"use client";
import React from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Menu, Database, Settings, Palette, Grid3X3, Save, Undo, Redo, Play, ArrowLeft } from "lucide-react";
import { cn } from "@/lib/utils";
interface DesignerToolbarProps {
screenName?: string;
tableName?: string;
onBack: () => void;
onSave: () => void;
onUndo: () => void;
onRedo: () => void;
onPreview: () => void;
onTogglePanel: (panelId: string) => void;
panelStates: Record<string, { isOpen: boolean }>;
canUndo: boolean;
canRedo: boolean;
isSaving?: boolean;
}
export const DesignerToolbar: React.FC<DesignerToolbarProps> = ({
screenName,
tableName,
onBack,
onSave,
onUndo,
onRedo,
onPreview,
onTogglePanel,
panelStates,
canUndo,
canRedo,
isSaving = false,
}) => {
return (
<div className="flex items-center justify-between border-b border-gray-200 bg-white px-4 py-3 shadow-sm">
{/* 좌측: 네비게이션 및 화면 정보 */}
<div className="flex items-center space-x-4">
<Button variant="ghost" size="sm" onClick={onBack} className="flex items-center space-x-2">
<ArrowLeft className="h-4 w-4" />
<span></span>
</Button>
<div className="h-6 w-px bg-gray-300" />
<div className="flex items-center space-x-3">
<Menu className="h-5 w-5 text-gray-600" />
<div>
<h1 className="text-lg font-semibold text-gray-900">{screenName || "화면 설계"}</h1>
{tableName && (
<div className="mt-0.5 flex items-center space-x-1">
<Database className="h-3 w-3 text-gray-500" />
<span className="font-mono text-xs text-gray-500">{tableName}</span>
</div>
)}
</div>
</div>
</div>
{/* 중앙: 패널 토글 버튼들 */}
<div className="flex items-center space-x-2">
<Button
variant={panelStates.tables?.isOpen ? "default" : "outline"}
size="sm"
onClick={() => onTogglePanel("tables")}
className={cn("flex items-center space-x-2", panelStates.tables?.isOpen && "bg-blue-600 text-white")}
>
<Database className="h-4 w-4" />
<span></span>
<Badge variant="secondary" className="ml-1 text-xs">
T
</Badge>
</Button>
<Button
variant={panelStates.properties?.isOpen ? "default" : "outline"}
size="sm"
onClick={() => onTogglePanel("properties")}
className={cn("flex items-center space-x-2", panelStates.properties?.isOpen && "bg-blue-600 text-white")}
>
<Settings className="h-4 w-4" />
<span></span>
<Badge variant="secondary" className="ml-1 text-xs">
P
</Badge>
</Button>
<Button
variant={panelStates.styles?.isOpen ? "default" : "outline"}
size="sm"
onClick={() => onTogglePanel("styles")}
className={cn("flex items-center space-x-2", panelStates.styles?.isOpen && "bg-blue-600 text-white")}
>
<Palette className="h-4 w-4" />
<span></span>
<Badge variant="secondary" className="ml-1 text-xs">
S
</Badge>
</Button>
<Button
variant={panelStates.grid?.isOpen ? "default" : "outline"}
size="sm"
onClick={() => onTogglePanel("grid")}
className={cn("flex items-center space-x-2", panelStates.grid?.isOpen && "bg-blue-600 text-white")}
>
<Grid3X3 className="h-4 w-4" />
<span></span>
<Badge variant="secondary" className="ml-1 text-xs">
G
</Badge>
</Button>
</div>
{/* 우측: 액션 버튼들 */}
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={onUndo}
disabled={!canUndo}
className="flex items-center space-x-1"
>
<Undo className="h-4 w-4" />
<span className="hidden sm:inline"></span>
</Button>
<Button
variant="outline"
size="sm"
onClick={onRedo}
disabled={!canRedo}
className="flex items-center space-x-1"
>
<Redo className="h-4 w-4" />
<span className="hidden sm:inline"></span>
</Button>
<div className="h-6 w-px bg-gray-300" />
<Button variant="outline" size="sm" onClick={onPreview} className="flex items-center space-x-2">
<Play className="h-4 w-4" />
<span></span>
</Button>
<Button onClick={onSave} disabled={isSaving} className="flex items-center space-x-2">
<Save className="h-4 w-4" />
<span>{isSaving ? "저장 중..." : "저장"}</span>
</Button>
</div>
</div>
);
};
export default DesignerToolbar;

View File

@ -0,0 +1,236 @@
"use client";
import React, { useState, useRef, useEffect } from "react";
import { X, GripVertical } from "lucide-react";
import { cn } from "@/lib/utils";
interface FloatingPanelProps {
id: string;
title: string;
children: React.ReactNode;
isOpen: boolean;
onClose: () => void;
position?: "left" | "right" | "top" | "bottom";
width?: number;
height?: number;
minWidth?: number;
minHeight?: number;
maxWidth?: number;
maxHeight?: number;
resizable?: boolean;
draggable?: boolean;
autoHeight?: boolean; // 자동 높이 조정 옵션
className?: string;
}
export const FloatingPanel: React.FC<FloatingPanelProps> = ({
id,
title,
children,
isOpen,
onClose,
position = "right",
width = 320,
height = 400,
minWidth = 280,
minHeight = 300,
maxWidth = 600,
maxHeight = 800,
resizable = true,
draggable = true,
autoHeight = false, // 자동 높이 조정 비활성화 (수동 크기 조절만 지원)
className,
}) => {
const [panelSize, setPanelSize] = useState({ width, height });
const [panelPosition, setPanelPosition] = useState({ x: 0, y: 0 });
const [isDragging, setIsDragging] = useState(false);
const [isResizing, setIsResizing] = useState(false);
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
const panelRef = useRef<HTMLDivElement>(null);
const dragHandleRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
// 초기 위치 설정 (패널이 처음 열릴 때만)
const [hasInitialized, setHasInitialized] = useState(false);
useEffect(() => {
if (isOpen && !hasInitialized && panelRef.current) {
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let initialX = 0;
let initialY = 0;
switch (position) {
case "left":
initialX = 20;
initialY = 80;
break;
case "right":
initialX = viewportWidth - panelSize.width - 20;
initialY = 80;
break;
case "top":
initialX = (viewportWidth - panelSize.width) / 2;
initialY = 20;
break;
case "bottom":
initialX = (viewportWidth - panelSize.width) / 2;
initialY = viewportHeight - panelSize.height - 20;
break;
}
setPanelPosition({ x: initialX, y: initialY });
setHasInitialized(true);
}
// 패널이 닫힐 때 초기화 상태 리셋
if (!isOpen) {
setHasInitialized(false);
}
}, [isOpen, position, hasInitialized]);
// 자동 높이 조정 기능 제거됨 - 수동 크기 조절만 지원
// 드래그 시작 - 성능 최적화
const handleDragStart = (e: React.MouseEvent) => {
if (!draggable) return;
e.preventDefault(); // 기본 동작 방지로 딜레이 제거
e.stopPropagation(); // 이벤트 버블링 방지
setIsDragging(true);
const rect = panelRef.current?.getBoundingClientRect();
if (rect) {
setDragOffset({
x: e.clientX - rect.left,
y: e.clientY - rect.top,
});
}
};
// 리사이즈 시작
const handleResizeStart = (e: React.MouseEvent) => {
if (!resizable) return;
e.preventDefault();
setIsResizing(true);
};
// 마우스 이동 처리 - 초고속 최적화
useEffect(() => {
if (!isDragging && !isResizing) return;
const handleMouseMove = (e: MouseEvent) => {
if (isDragging) {
// 직접 DOM 조작으로 최고 성능
if (panelRef.current) {
const newX = e.clientX - dragOffset.x;
const newY = e.clientY - dragOffset.y;
panelRef.current.style.left = `${newX}px`;
panelRef.current.style.top = `${newY}px`;
// 상태는 throttle로 업데이트
setPanelPosition({ x: newX, y: newY });
}
} else if (isResizing) {
const newWidth = Math.max(minWidth, Math.min(maxWidth, e.clientX - panelPosition.x));
const newHeight = Math.max(minHeight, Math.min(maxHeight, e.clientY - panelPosition.y));
if (panelRef.current) {
panelRef.current.style.width = `${newWidth}px`;
panelRef.current.style.height = `${newHeight}px`;
}
setPanelSize({ width: newWidth, height: newHeight });
}
};
const handleMouseUp = () => {
setIsDragging(false);
setIsResizing(false);
};
// 고성능 이벤트 리스너
document.addEventListener("mousemove", handleMouseMove, {
passive: true,
capture: false,
});
document.addEventListener("mouseup", handleMouseUp, {
passive: true,
capture: false,
});
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}, [isDragging, isResizing, dragOffset.x, dragOffset.y, panelPosition.x, minWidth, maxWidth, minHeight, maxHeight]);
if (!isOpen) return null;
return (
<div
ref={panelRef}
className={cn(
"fixed z-50 rounded-lg border border-gray-200 bg-white shadow-lg",
isDragging ? "cursor-move shadow-2xl" : "transition-all duration-200 ease-in-out",
isResizing && "cursor-se-resize",
className,
)}
style={{
left: `${panelPosition.x}px`,
top: `${panelPosition.y}px`,
width: `${panelSize.width}px`,
height: `${panelSize.height}px`,
transform: isDragging ? "scale(1.01)" : "scale(1)",
transition: isDragging ? "none" : "transform 0.1s ease-out, box-shadow 0.1s ease-out",
zIndex: isDragging ? 9999 : 50, // 드래그 중 최상위 표시
}}
>
{/* 헤더 */}
<div
ref={dragHandleRef}
data-header="true"
className="flex cursor-move items-center justify-between rounded-t-lg border-b border-gray-200 bg-gray-50 p-3"
onMouseDown={handleDragStart}
style={{
userSelect: "none", // 텍스트 선택 방지
WebkitUserSelect: "none",
MozUserSelect: "none",
msUserSelect: "none",
}}
>
<div className="flex items-center space-x-2">
<GripVertical className="h-4 w-4 text-gray-400" />
<h3 className="text-sm font-medium text-gray-900">{title}</h3>
</div>
<button onClick={onClose} className="rounded p-1 transition-colors hover:bg-gray-200">
<X className="h-4 w-4 text-gray-500" />
</button>
</div>
{/* 컨텐츠 */}
<div
ref={contentRef}
className="flex-1 overflow-auto"
style={{
maxHeight: `${panelSize.height - 60}px`, // 헤더 높이 제외
}}
>
{children}
</div>
{/* 리사이즈 핸들 */}
{resizable && (
<div className="absolute right-0 bottom-0 h-4 w-4 cursor-se-resize" onMouseDown={handleResizeStart}>
<div className="absolute right-1 bottom-1 h-2 w-2 rounded-sm bg-gray-400" />
</div>
)}
</div>
);
};
export default FloatingPanel;

View File

@ -252,6 +252,7 @@ export const RealtimePreview: React.FC<RealtimePreviewProps> = ({
top: `${component.position.y}px`,
width: `${size.width}px`, // 격자 기반 계산 제거
height: `${size.height}px`,
zIndex: component.position.z || 1, // z-index 적용
...selectionStyle,
}}
onClick={(e) => {

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,667 @@
"use client";
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { Group, Database, Trash2, Copy, Clipboard } from "lucide-react";
import {
ScreenDefinition,
ComponentData,
LayoutData,
GroupState,
WebType,
TableInfo,
GroupComponent,
Position,
} from "@/types/screen";
import { generateComponentId } from "@/lib/utils/generateId";
import {
createGroupComponent,
calculateBoundingBox,
calculateRelativePositions,
restoreAbsolutePositions,
getGroupChildren,
} from "@/lib/utils/groupingUtils";
import {
calculateGridInfo,
snapToGrid,
snapSizeToGrid,
generateGridLines,
GridSettings as GridUtilSettings,
} from "@/lib/utils/gridUtils";
import { GroupingToolbar } from "./GroupingToolbar";
import { screenApi } from "@/lib/api/screen";
import { toast } from "sonner";
import StyleEditor from "./StyleEditor";
import { RealtimePreview } from "./RealtimePreview";
import FloatingPanel from "./FloatingPanel";
import DesignerToolbar from "./DesignerToolbar";
import TablesPanel from "./panels/TablesPanel";
import PropertiesPanel from "./panels/PropertiesPanel";
import GridPanel from "./panels/GridPanel";
import { usePanelState, PanelConfig } from "@/hooks/usePanelState";
interface ScreenDesignerProps {
selectedScreen: ScreenDefinition | null;
onBackToList: () => void;
}
// 패널 설정
const panelConfigs: PanelConfig[] = [
{
id: "tables",
title: "테이블 목록",
defaultPosition: "left",
defaultWidth: 320,
defaultHeight: 600,
shortcutKey: "t",
},
{
id: "properties",
title: "속성 편집",
defaultPosition: "right",
defaultWidth: 320,
defaultHeight: 500,
shortcutKey: "p",
},
{
id: "styles",
title: "스타일 편집",
defaultPosition: "right",
defaultWidth: 320,
defaultHeight: 400,
shortcutKey: "s",
},
{
id: "grid",
title: "격자 설정",
defaultPosition: "right",
defaultWidth: 280,
defaultHeight: 450,
shortcutKey: "g",
},
];
export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenDesignerProps) {
// 패널 상태 관리
const { panelStates, togglePanel, openPanel, closePanel, closeAllPanels } = usePanelState(panelConfigs);
const [layout, setLayout] = useState<LayoutData>({
components: [],
gridSettings: { columns: 12, gap: 16, padding: 16, snapToGrid: true, showGrid: true },
});
const [isSaving, setIsSaving] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [selectedComponent, setSelectedComponent] = useState<ComponentData | null>(null);
// 실행취소/다시실행을 위한 히스토리 상태
const [history, setHistory] = useState<LayoutData[]>([]);
const [historyIndex, setHistoryIndex] = useState(-1);
// 그룹 상태
const [groupState, setGroupState] = useState<GroupState>({
selectedComponents: [],
isGrouping: false,
});
// 드래그 상태
const [dragState, setDragState] = useState({
isDragging: false,
draggedComponent: null as ComponentData | null,
originalPosition: { x: 0, y: 0 },
currentPosition: { x: 0, y: 0 },
grabOffset: { x: 0, y: 0 },
});
// 테이블 데이터
const [tables, setTables] = useState<TableInfo[]>([]);
const [searchTerm, setSearchTerm] = useState("");
// 클립보드
const [clipboard, setClipboard] = useState<{
type: "single" | "multiple" | "group";
data: ComponentData[];
} | null>(null);
// 그룹 생성 다이얼로그
const [showGroupCreateDialog, setShowGroupCreateDialog] = useState(false);
const canvasRef = useRef<HTMLDivElement>(null);
// 격자 정보 계산
const gridInfo = useMemo(() => {
if (!canvasRef.current || !layout.gridSettings) return null;
return calculateGridInfo(canvasRef.current, layout.gridSettings);
}, [layout.gridSettings]);
// 격자 라인 생성
const gridLines = useMemo(() => {
if (!gridInfo || !layout.gridSettings?.showGrid) return [];
return generateGridLines(gridInfo, layout.gridSettings);
}, [gridInfo, layout.gridSettings]);
// 필터된 테이블 목록
const filteredTables = useMemo(() => {
if (!searchTerm) return tables;
return tables.filter(
(table) =>
table.tableName.toLowerCase().includes(searchTerm.toLowerCase()) ||
table.columns.some((col) => col.columnName.toLowerCase().includes(searchTerm.toLowerCase())),
);
}, [tables, searchTerm]);
// 히스토리에 저장
const saveToHistory = useCallback(
(newLayout: LayoutData) => {
setHistory((prev) => {
const newHistory = prev.slice(0, historyIndex + 1);
newHistory.push(newLayout);
return newHistory.slice(-50); // 최대 50개까지만 저장
});
setHistoryIndex((prev) => Math.min(prev + 1, 49));
setHasUnsavedChanges(true);
},
[historyIndex],
);
// 실행취소
const undo = useCallback(() => {
if (historyIndex > 0) {
setHistoryIndex((prev) => prev - 1);
setLayout(history[historyIndex - 1]);
}
}, [history, historyIndex]);
// 다시실행
const redo = useCallback(() => {
if (historyIndex < history.length - 1) {
setHistoryIndex((prev) => prev + 1);
setLayout(history[historyIndex + 1]);
}
}, [history, historyIndex]);
// 컴포넌트 속성 업데이트
const updateComponentProperty = useCallback(
(componentId: string, path: string, value: any) => {
const pathParts = path.split(".");
const updatedComponents = layout.components.map((comp) => {
if (comp.id !== componentId) return comp;
const newComp = { ...comp };
let current: any = newComp;
for (let i = 0; i < pathParts.length - 1; i++) {
if (!current[pathParts[i]]) {
current[pathParts[i]] = {};
}
current = current[pathParts[i]];
}
current[pathParts[pathParts.length - 1]] = value;
// 크기 변경 시 격자 스냅 적용
if ((path === "size.width" || path === "size.height") && layout.gridSettings?.snapToGrid && gridInfo) {
const snappedSize = snapSizeToGrid(newComp.size, gridInfo, layout.gridSettings as GridUtilSettings);
newComp.size = snappedSize;
}
return newComp;
});
const newLayout = { ...layout, components: updatedComponents };
setLayout(newLayout);
saveToHistory(newLayout);
},
[layout, gridInfo, saveToHistory],
);
// 테이블 데이터 로드
useEffect(() => {
if (selectedScreen?.tableName) {
const loadTables = async () => {
try {
setIsLoading(true);
const response = await screenApi.getTableInfo([selectedScreen.tableName]);
setTables(response.data || []);
} catch (error) {
console.error("테이블 정보 로드 실패:", error);
toast.error("테이블 정보를 불러오는데 실패했습니다.");
} finally {
setIsLoading(false);
}
};
loadTables();
}
}, [selectedScreen?.tableName]);
// 화면 레이아웃 로드
useEffect(() => {
if (selectedScreen?.screenId) {
const loadLayout = async () => {
try {
setIsLoading(true);
const response = await screenApi.getScreenLayout(selectedScreen.screenId);
if (response.success && response.data) {
setLayout(response.data);
setHistory([response.data]);
setHistoryIndex(0);
setHasUnsavedChanges(false);
}
} catch (error) {
console.error("레이아웃 로드 실패:", error);
toast.error("화면 레이아웃을 불러오는데 실패했습니다.");
} finally {
setIsLoading(false);
}
};
loadLayout();
}
}, [selectedScreen?.screenId]);
// 저장
const handleSave = useCallback(async () => {
if (!selectedScreen?.screenId) return;
try {
setIsSaving(true);
const response = await screenApi.saveScreenLayout(selectedScreen.screenId, layout);
if (response.success) {
toast.success("화면이 저장되었습니다.");
setHasUnsavedChanges(false);
} else {
toast.error("저장에 실패했습니다.");
}
} catch (error) {
console.error("저장 실패:", error);
toast.error("저장 중 오류가 발생했습니다.");
} finally {
setIsSaving(false);
}
}, [selectedScreen?.screenId, layout]);
// 드래그 앤 드롭 처리
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
}, []);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
const dragData = e.dataTransfer.getData("application/json");
if (!dragData) return;
try {
const { type, table, column } = JSON.parse(dragData);
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
let newComponent: ComponentData;
if (type === "table") {
// 테이블 컨테이너 생성
newComponent = {
id: generateComponentId(),
type: "container",
label: table.tableName,
tableName: table.tableName,
position: { x, y, z: 1 },
size: { width: 300, height: 200 },
};
} else if (type === "column") {
// 컬럼 위젯 생성
newComponent = {
id: generateComponentId(),
type: "widget",
label: column.columnName,
tableName: table.tableName,
columnName: column.columnName,
widgetType: column.widgetType,
dataType: column.dataType,
required: column.required,
position: { x, y, z: 1 },
size: { width: 200, height: 40 },
};
} else {
return;
}
// 격자 스냅 적용
if (layout.gridSettings?.snapToGrid && gridInfo) {
newComponent.position = snapToGrid(newComponent.position, gridInfo, layout.gridSettings as GridUtilSettings);
newComponent.size = snapSizeToGrid(newComponent.size, gridInfo, layout.gridSettings as GridUtilSettings);
}
const newLayout = {
...layout,
components: [...layout.components, newComponent],
};
setLayout(newLayout);
saveToHistory(newLayout);
setSelectedComponent(newComponent);
// 속성 패널 자동 열기
openPanel("properties");
} catch (error) {
console.error("드롭 처리 실패:", error);
}
},
[layout, gridInfo, saveToHistory, openPanel],
);
// 컴포넌트 클릭 처리
const handleComponentClick = useCallback(
(component: ComponentData, event?: React.MouseEvent) => {
event?.stopPropagation();
setSelectedComponent(component);
// 속성 패널 자동 열기
openPanel("properties");
},
[openPanel],
);
// 컴포넌트 삭제
const deleteComponent = useCallback(() => {
if (!selectedComponent) return;
const newComponents = layout.components.filter((comp) => comp.id !== selectedComponent.id);
const newLayout = { ...layout, components: newComponents };
setLayout(newLayout);
saveToHistory(newLayout);
setSelectedComponent(null);
}, [selectedComponent, layout, saveToHistory]);
// 컴포넌트 복사
const copyComponent = useCallback(() => {
if (!selectedComponent) return;
setClipboard({
type: "single",
data: [{ ...selectedComponent, id: generateComponentId() }],
});
toast.success("컴포넌트가 복사되었습니다.");
}, [selectedComponent]);
// 그룹 생성
const handleGroupCreate = useCallback(
(componentIds: string[], title: string, style?: any) => {
const selectedComponents = layout.components.filter((comp) => componentIds.includes(comp.id));
if (selectedComponents.length < 2) return;
// 경계 박스 계산
const boundingBox = calculateBoundingBox(selectedComponents);
// 그룹 컴포넌트 생성
const groupComponent = createGroupComponent(
componentIds,
title,
{ x: boundingBox.minX, y: boundingBox.minY },
{ width: boundingBox.width, height: boundingBox.height },
style,
);
// 자식 컴포넌트들의 상대 위치 계산
const relativeChildren = calculateRelativePositions(
selectedComponents,
{ x: boundingBox.minX, y: boundingBox.minY },
groupComponent.id,
);
const newLayout = {
...layout,
components: [
...layout.components.filter((comp) => !componentIds.includes(comp.id)),
groupComponent,
...relativeChildren,
],
};
setLayout(newLayout);
saveToHistory(newLayout);
setGroupState((prev) => ({ ...prev, selectedComponents: [] }));
},
[layout, saveToHistory],
);
// 키보드 이벤트 처리
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Delete 키로 컴포넌트 삭제
if (e.key === "Delete" && selectedComponent) {
deleteComponent();
}
// Ctrl+C로 복사
if (e.ctrlKey && e.key === "c" && selectedComponent) {
copyComponent();
}
// Ctrl+Z로 실행취소
if (e.ctrlKey && e.key === "z" && !e.shiftKey) {
e.preventDefault();
undo();
}
// Ctrl+Y 또는 Ctrl+Shift+Z로 다시실행
if ((e.ctrlKey && e.key === "y") || (e.ctrlKey && e.shiftKey && e.key === "z")) {
e.preventDefault();
redo();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [selectedComponent, deleteComponent, copyComponent, undo, redo]);
if (!selectedScreen) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<Database className="mx-auto mb-4 h-12 w-12 text-gray-400" />
<h3 className="text-lg font-medium text-gray-900"> </h3>
<p className="text-gray-500"> .</p>
</div>
</div>
);
}
return (
<div className="flex h-screen w-full flex-col bg-gray-100">
{/* 상단 툴바 */}
<DesignerToolbar
screenName={selectedScreen?.screenName}
tableName={selectedScreen?.tableName}
onBack={onBackToList}
onSave={handleSave}
onUndo={undo}
onRedo={redo}
onPreview={() => {
toast.info("미리보기 기능은 준비 중입니다.");
}}
onTogglePanel={togglePanel}
panelStates={panelStates}
canUndo={historyIndex > 0}
canRedo={historyIndex < history.length - 1}
isSaving={isSaving}
/>
{/* 메인 캔버스 영역 (전체 화면) */}
<div
ref={canvasRef}
className="relative flex-1 overflow-hidden bg-white"
onClick={(e) => {
if (e.target === e.currentTarget) {
setSelectedComponent(null);
setGroupState((prev) => ({ ...prev, selectedComponents: [] }));
}
}}
onDrop={handleDrop}
onDragOver={handleDragOver}
>
{/* 격자 라인 */}
{gridLines.map((line, index) => (
<div
key={index}
className="pointer-events-none absolute"
style={{
left: line.type === "vertical" ? `${line.position}px` : 0,
top: line.type === "horizontal" ? `${line.position}px` : 0,
width: line.type === "vertical" ? "1px" : "100%",
height: line.type === "horizontal" ? "1px" : "100%",
backgroundColor: layout.gridSettings?.gridColor || "#e5e7eb",
opacity: layout.gridSettings?.gridOpacity || 0.3,
}}
/>
))}
{/* 컴포넌트들 */}
{layout.components
.filter((component) => !component.parentId) // 최상위 컴포넌트만 렌더링
.map((component) => {
const children =
component.type === "group" ? layout.components.filter((child) => child.parentId === component.id) : [];
return (
<RealtimePreview
key={component.id}
component={component}
isSelected={selectedComponent?.id === component.id}
onClick={(e) => handleComponentClick(component, e)}
>
{children.map((child) => (
<RealtimePreview
key={child.id}
component={child}
isSelected={selectedComponent?.id === child.id}
onClick={(e) => handleComponentClick(child, e)}
/>
))}
</RealtimePreview>
);
})}
{/* 빈 캔버스 안내 */}
{layout.components.length === 0 && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="text-center text-gray-400">
<Database className="mx-auto mb-4 h-16 w-16" />
<h3 className="mb-2 text-xl font-medium"> </h3>
<p className="text-sm"> </p>
<p className="mt-2 text-xs">단축키: T(), P(), S(), G()</p>
</div>
</div>
)}
</div>
{/* 플로팅 패널들 */}
<FloatingPanel
id="tables"
title="테이블 목록"
isOpen={panelStates.tables?.isOpen || false}
onClose={() => closePanel("tables")}
position="left"
width={320}
height={600}
>
<TablesPanel
tables={filteredTables}
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
onDragStart={(e, table, column) => {
const dragData = {
type: column ? "column" : "table",
table,
column,
};
e.dataTransfer.setData("application/json", JSON.stringify(dragData));
}}
selectedTableName={selectedScreen.tableName}
/>
</FloatingPanel>
<FloatingPanel
id="properties"
title="속성 편집"
isOpen={panelStates.properties?.isOpen || false}
onClose={() => closePanel("properties")}
position="right"
width={320}
height={500}
>
<PropertiesPanel
selectedComponent={selectedComponent}
onUpdateProperty={updateComponentProperty}
onDeleteComponent={deleteComponent}
onCopyComponent={copyComponent}
/>
</FloatingPanel>
<FloatingPanel
id="styles"
title="스타일 편집"
isOpen={panelStates.styles?.isOpen || false}
onClose={() => closePanel("styles")}
position="right"
width={320}
height={400}
>
{selectedComponent ? (
<div className="p-4">
<StyleEditor
style={selectedComponent.style || {}}
onStyleChange={(newStyle) => updateComponentProperty(selectedComponent.id, "style", newStyle)}
/>
</div>
) : (
<div className="flex h-full items-center justify-center text-gray-500">
</div>
)}
</FloatingPanel>
<FloatingPanel
id="grid"
title="격자 설정"
isOpen={panelStates.grid?.isOpen || false}
onClose={() => closePanel("grid")}
position="right"
width={280}
height={450}
>
<GridPanel
gridSettings={layout.gridSettings || { columns: 12, gap: 16, padding: 16, snapToGrid: true, showGrid: true }}
onGridSettingsChange={(settings) => {
const newLayout = { ...layout, gridSettings: settings };
setLayout(newLayout);
saveToHistory(newLayout);
}}
onResetGrid={() => {
const defaultSettings = { columns: 12, gap: 16, padding: 16, snapToGrid: true, showGrid: true };
const newLayout = { ...layout, gridSettings: defaultSettings };
setLayout(newLayout);
saveToHistory(newLayout);
}}
/>
</FloatingPanel>
{/* 그룹 생성 툴바 (필요시) */}
{groupState.selectedComponents.length > 1 && (
<div className="fixed bottom-4 left-1/2 z-50 -translate-x-1/2 transform">
<GroupingToolbar
selectedComponents={groupState.selectedComponents}
onGroupCreate={handleGroupCreate}
showCreateDialog={showGroupCreateDialog}
onShowCreateDialogChange={setShowGroupCreateDialog}
/>
</div>
)}
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,223 @@
"use client";
import React from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { Slider } from "@/components/ui/slider";
import { Grid3X3, RotateCcw, Eye, EyeOff, Zap } from "lucide-react";
import { GridSettings } from "@/types/screen";
interface GridPanelProps {
gridSettings: GridSettings;
onGridSettingsChange: (settings: GridSettings) => void;
onResetGrid: () => void;
}
export const GridPanel: React.FC<GridPanelProps> = ({ gridSettings, onGridSettingsChange, onResetGrid }) => {
const updateSetting = (key: keyof GridSettings, value: any) => {
onGridSettingsChange({
...gridSettings,
[key]: value,
});
};
return (
<div className="flex h-full flex-col">
{/* 헤더 */}
<div className="border-b border-gray-200 p-4">
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center space-x-2">
<Grid3X3 className="h-4 w-4 text-gray-600" />
<h3 className="font-medium text-gray-900"> </h3>
</div>
<Button size="sm" variant="outline" onClick={onResetGrid} className="flex items-center space-x-1">
<RotateCcw className="h-3 w-3" />
<span></span>
</Button>
</div>
{/* 주요 토글들 */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
{gridSettings.showGrid ? (
<Eye className="h-4 w-4 text-blue-600" />
) : (
<EyeOff className="h-4 w-4 text-gray-400" />
)}
<Label htmlFor="showGrid" className="text-sm font-medium">
</Label>
</div>
<Checkbox
id="showGrid"
checked={gridSettings.showGrid}
onCheckedChange={(checked) => updateSetting("showGrid", checked)}
/>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Zap className="h-4 w-4 text-green-600" />
<Label htmlFor="snapToGrid" className="text-sm font-medium">
</Label>
</div>
<Checkbox
id="snapToGrid"
checked={gridSettings.snapToGrid}
onCheckedChange={(checked) => updateSetting("snapToGrid", checked)}
/>
</div>
</div>
</div>
{/* 설정 영역 */}
<div className="flex-1 space-y-6 overflow-y-auto p-4">
{/* 격자 구조 */}
<div className="space-y-4">
<h4 className="font-medium text-gray-900"> </h4>
<div>
<Label htmlFor="columns" className="mb-2 block text-sm font-medium">
: {gridSettings.columns}
</Label>
<Slider
id="columns"
min={1}
max={24}
step={1}
value={[gridSettings.columns]}
onValueChange={([value]) => updateSetting("columns", value)}
className="w-full"
/>
<div className="mt-1 flex justify-between text-xs text-gray-500">
<span>1</span>
<span>24</span>
</div>
</div>
<div>
<Label htmlFor="gap" className="mb-2 block text-sm font-medium">
: {gridSettings.gap}px
</Label>
<Slider
id="gap"
min={0}
max={40}
step={2}
value={[gridSettings.gap]}
onValueChange={([value]) => updateSetting("gap", value)}
className="w-full"
/>
<div className="mt-1 flex justify-between text-xs text-gray-500">
<span>0px</span>
<span>40px</span>
</div>
</div>
<div>
<Label htmlFor="padding" className="mb-2 block text-sm font-medium">
: {gridSettings.padding}px
</Label>
<Slider
id="padding"
min={0}
max={60}
step={4}
value={[gridSettings.padding]}
onValueChange={([value]) => updateSetting("padding", value)}
className="w-full"
/>
<div className="mt-1 flex justify-between text-xs text-gray-500">
<span>0px</span>
<span>60px</span>
</div>
</div>
</div>
<Separator />
{/* 격자 스타일 */}
<div className="space-y-4">
<h4 className="font-medium text-gray-900"> </h4>
<div>
<Label htmlFor="gridColor" className="text-sm font-medium">
</Label>
<div className="mt-1 flex items-center space-x-2">
<Input
id="gridColor"
type="color"
value={gridSettings.gridColor || "#d1d5db"}
onChange={(e) => updateSetting("gridColor", e.target.value)}
className="h-8 w-12 rounded border p-1"
/>
<Input
type="text"
value={gridSettings.gridColor || "#d1d5db"}
onChange={(e) => updateSetting("gridColor", e.target.value)}
placeholder="#d1d5db"
className="flex-1"
/>
</div>
</div>
<div>
<Label htmlFor="gridOpacity" className="mb-2 block text-sm font-medium">
: {Math.round((gridSettings.gridOpacity || 0.5) * 100)}%
</Label>
<Slider
id="gridOpacity"
min={0.1}
max={1}
step={0.1}
value={[gridSettings.gridOpacity || 0.5]}
onValueChange={([value]) => updateSetting("gridOpacity", value)}
className="w-full"
/>
<div className="mt-1 flex justify-between text-xs text-gray-500">
<span>10%</span>
<span>100%</span>
</div>
</div>
</div>
<Separator />
{/* 미리보기 */}
<div className="space-y-3">
<h4 className="font-medium text-gray-900"></h4>
<div
className="rounded-md border border-gray-200 bg-white p-4"
style={{
backgroundImage: gridSettings.showGrid
? `linear-gradient(to right, ${gridSettings.gridColor || "#d1d5db"} 1px, transparent 1px),
linear-gradient(to bottom, ${gridSettings.gridColor || "#d1d5db"} 1px, transparent 1px)`
: "none",
backgroundSize: gridSettings.showGrid ? `${100 / gridSettings.columns}% 20px` : "none",
opacity: gridSettings.gridOpacity || 0.5,
}}
>
<div className="flex h-16 items-center justify-center rounded border-2 border-dashed border-blue-300 bg-blue-100">
<span className="text-xs text-blue-600"> </span>
</div>
</div>
</div>
</div>
{/* 푸터 */}
<div className="border-t border-gray-200 bg-gray-50 p-3">
<div className="text-xs text-gray-600">💡 </div>
</div>
</div>
);
};
export default GridPanel;

View File

@ -0,0 +1,456 @@
"use client";
import React, { useState, useCallback, useEffect, useRef } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox";
import { Textarea } from "@/components/ui/textarea";
import { Settings, Move, Resize, Type, Palette, Trash2, Copy, Group, Ungroup } from "lucide-react";
import { ComponentData, WebType } from "@/types/screen";
interface PropertiesPanelProps {
selectedComponent?: ComponentData;
onUpdateProperty: (path: string, value: any) => void;
onDeleteComponent: () => void;
onCopyComponent: () => void;
onGroupComponents?: () => void;
onUngroupComponents?: () => void;
canGroup?: boolean;
canUngroup?: boolean;
}
const webTypeOptions: { value: WebType; label: string }[] = [
{ value: "text", label: "텍스트" },
{ value: "email", label: "이메일" },
{ value: "tel", label: "전화번호" },
{ value: "number", label: "숫자" },
{ value: "decimal", label: "소수" },
{ value: "date", label: "날짜" },
{ value: "datetime", label: "날짜시간" },
{ value: "select", label: "선택박스" },
{ value: "dropdown", label: "드롭다운" },
{ value: "textarea", label: "텍스트영역" },
{ value: "boolean", label: "불린" },
{ value: "checkbox", label: "체크박스" },
{ value: "radio", label: "라디오" },
{ value: "code", label: "코드" },
{ value: "entity", label: "엔티티" },
{ value: "file", label: "파일" },
];
// Debounce hook for better performance
const useDebounce = (value: any, delay: number) => {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
};
export const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
selectedComponent,
onUpdateProperty,
onDeleteComponent,
onCopyComponent,
onGroupComponents,
onUngroupComponents,
canGroup = false,
canUngroup = false,
}) => {
// 최신 값들의 참조를 유지
const selectedComponentRef = useRef(selectedComponent);
const onUpdatePropertyRef = useRef(onUpdateProperty);
useEffect(() => {
selectedComponentRef.current = selectedComponent;
onUpdatePropertyRef.current = onUpdateProperty;
});
// 로컬 상태 관리 (실시간 입력 반영용)
const [localValues, setLocalValues] = useState({
label: selectedComponent?.label || "",
placeholder: selectedComponent?.placeholder || "",
title: selectedComponent?.title || "",
positionX: selectedComponent?.position.x || 0,
positionY: selectedComponent?.position.y || 0,
positionZ: selectedComponent?.position.z || 1,
width: selectedComponent?.size.width || 0,
height: selectedComponent?.size.height || 0,
});
// 선택된 컴포넌트가 변경될 때 로컬 상태 업데이트
useEffect(() => {
if (selectedComponent) {
setLocalValues({
label: selectedComponent.label || "",
placeholder: selectedComponent.placeholder || "",
title: selectedComponent.title || "",
positionX: selectedComponent.position.x || 0,
positionY: selectedComponent.position.y || 0,
positionZ: selectedComponent.position.z || 1,
width: selectedComponent.size.width || 0,
height: selectedComponent.size.height || 0,
});
}
}, [selectedComponent]);
// Debounce된 값들
const debouncedLabel = useDebounce(localValues.label, 300);
const debouncedPlaceholder = useDebounce(localValues.placeholder, 300);
const debouncedTitle = useDebounce(localValues.title, 300);
const debouncedPositionX = useDebounce(localValues.positionX, 150);
const debouncedPositionY = useDebounce(localValues.positionY, 150);
const debouncedPositionZ = useDebounce(localValues.positionZ, 150);
const debouncedWidth = useDebounce(localValues.width, 150);
const debouncedHeight = useDebounce(localValues.height, 150);
// Debounce된 값이 변경될 때 실제 업데이트
useEffect(() => {
const currentComponent = selectedComponentRef.current;
const updateProperty = onUpdatePropertyRef.current;
if (currentComponent && debouncedLabel !== currentComponent.label && debouncedLabel) {
updateProperty("label", debouncedLabel);
}
}, [debouncedLabel]);
useEffect(() => {
const currentComponent = selectedComponentRef.current;
const updateProperty = onUpdatePropertyRef.current;
if (currentComponent && debouncedPlaceholder !== currentComponent.placeholder) {
updateProperty("placeholder", debouncedPlaceholder);
}
}, [debouncedPlaceholder]);
useEffect(() => {
const currentComponent = selectedComponentRef.current;
const updateProperty = onUpdatePropertyRef.current;
if (currentComponent && debouncedTitle !== currentComponent.title) {
updateProperty("title", debouncedTitle);
}
}, [debouncedTitle]);
useEffect(() => {
const currentComponent = selectedComponentRef.current;
const updateProperty = onUpdatePropertyRef.current;
if (currentComponent && debouncedPositionX !== currentComponent.position.x) {
updateProperty("position.x", debouncedPositionX);
}
}, [debouncedPositionX]);
useEffect(() => {
const currentComponent = selectedComponentRef.current;
const updateProperty = onUpdatePropertyRef.current;
if (currentComponent && debouncedPositionY !== currentComponent.position.y) {
updateProperty("position.y", debouncedPositionY);
}
}, [debouncedPositionY]);
useEffect(() => {
const currentComponent = selectedComponentRef.current;
const updateProperty = onUpdatePropertyRef.current;
if (currentComponent && debouncedPositionZ !== currentComponent.position.z) {
updateProperty("position.z", debouncedPositionZ);
}
}, [debouncedPositionZ]);
useEffect(() => {
const currentComponent = selectedComponentRef.current;
const updateProperty = onUpdatePropertyRef.current;
if (currentComponent && debouncedWidth !== currentComponent.size.width) {
updateProperty("size.width", debouncedWidth);
}
}, [debouncedWidth]);
useEffect(() => {
const currentComponent = selectedComponentRef.current;
const updateProperty = onUpdatePropertyRef.current;
if (currentComponent && debouncedHeight !== currentComponent.size.height) {
updateProperty("size.height", debouncedHeight);
}
}, [debouncedHeight]);
if (!selectedComponent) {
return (
<div className="flex h-full flex-col items-center justify-center p-6 text-center">
<Settings className="mb-4 h-12 w-12 text-gray-400" />
<h3 className="mb-2 text-lg font-medium text-gray-900"> </h3>
<p className="text-sm text-gray-500"> .</p>
</div>
);
}
return (
<div className="flex h-full flex-col">
{/* 헤더 */}
<div className="border-b border-gray-200 p-4">
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center space-x-2">
<Settings className="h-4 w-4 text-gray-600" />
<h3 className="font-medium text-gray-900"> </h3>
</div>
<Badge variant="secondary" className="text-xs">
{selectedComponent.type}
</Badge>
</div>
{/* 액션 버튼들 */}
<div className="flex flex-wrap gap-2">
<Button size="sm" variant="outline" onClick={onCopyComponent} className="flex items-center space-x-1">
<Copy className="h-3 w-3" />
<span></span>
</Button>
{canGroup && (
<Button size="sm" variant="outline" onClick={onGroupComponents} className="flex items-center space-x-1">
<Group className="h-3 w-3" />
<span></span>
</Button>
)}
{canUngroup && (
<Button size="sm" variant="outline" onClick={onUngroupComponents} className="flex items-center space-x-1">
<Ungroup className="h-3 w-3" />
<span></span>
</Button>
)}
<Button size="sm" variant="destructive" onClick={onDeleteComponent} className="flex items-center space-x-1">
<Trash2 className="h-3 w-3" />
<span></span>
</Button>
</div>
</div>
{/* 속성 편집 영역 */}
<div className="flex-1 space-y-6 overflow-y-auto p-4">
{/* 기본 정보 */}
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Type className="h-4 w-4 text-gray-600" />
<h4 className="font-medium text-gray-900"> </h4>
</div>
<div className="space-y-3">
<div>
<Label htmlFor="label" className="text-sm font-medium">
</Label>
<Input
id="label"
value={localValues.label}
onChange={(e) => setLocalValues((prev) => ({ ...prev, label: e.target.value }))}
placeholder="컴포넌트 라벨"
className="mt-1"
/>
</div>
{selectedComponent.type === "widget" && (
<>
<div>
<Label htmlFor="columnName" className="text-sm font-medium">
( )
</Label>
<Input
id="columnName"
value={selectedComponent.columnName || ""}
readOnly
placeholder="데이터베이스 컬럼명"
className="mt-1 bg-gray-50 text-gray-600"
title="컬럼명은 변경할 수 없습니다"
/>
</div>
<div>
<Label htmlFor="widgetType" className="text-sm font-medium">
</Label>
<Select
value={selectedComponent.widgetType || "text"}
onValueChange={(value) => onUpdateProperty("widgetType", value)}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{webTypeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="placeholder" className="text-sm font-medium">
</Label>
<Input
id="placeholder"
value={localValues.placeholder}
onChange={(e) => setLocalValues((prev) => ({ ...prev, placeholder: e.target.value }))}
placeholder="입력 힌트 텍스트"
className="mt-1"
/>
</div>
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<Checkbox
id="required"
checked={selectedComponent.required || false}
onCheckedChange={(checked) => onUpdateProperty("required", checked)}
/>
<Label htmlFor="required" className="text-sm">
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="readonly"
checked={selectedComponent.readonly || false}
onCheckedChange={(checked) => onUpdateProperty("readonly", checked)}
/>
<Label htmlFor="readonly" className="text-sm">
</Label>
</div>
</div>
</>
)}
</div>
</div>
<Separator />
{/* 위치 및 크기 */}
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Move className="h-4 w-4 text-gray-600" />
<h4 className="font-medium text-gray-900"> </h4>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="positionX" className="text-sm font-medium">
X
</Label>
<Input
id="positionX"
type="number"
value={localValues.positionX}
onChange={(e) => setLocalValues((prev) => ({ ...prev, positionX: Number(e.target.value) }))}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="positionY" className="text-sm font-medium">
Y
</Label>
<Input
id="positionY"
type="number"
value={localValues.positionY}
onChange={(e) => setLocalValues((prev) => ({ ...prev, positionY: Number(e.target.value) }))}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="width" className="text-sm font-medium">
</Label>
<Input
id="width"
type="number"
value={localValues.width}
onChange={(e) => setLocalValues((prev) => ({ ...prev, width: Number(e.target.value) }))}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="height" className="text-sm font-medium">
</Label>
<Input
id="height"
type="number"
value={localValues.height}
onChange={(e) => setLocalValues((prev) => ({ ...prev, height: Number(e.target.value) }))}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="zIndex" className="text-sm font-medium">
Z-Index ( )
</Label>
<Input
id="zIndex"
type="number"
min="0"
max="9999"
value={localValues.positionZ}
onChange={(e) => setLocalValues((prev) => ({ ...prev, positionZ: Number(e.target.value) }))}
className="mt-1"
placeholder="1"
/>
</div>
</div>
</div>
{selectedComponent.type === "group" && (
<>
<Separator />
{/* 그룹 설정 */}
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Group className="h-4 w-4 text-gray-600" />
<h4 className="font-medium text-gray-900"> </h4>
</div>
<div>
<Label htmlFor="groupTitle" className="text-sm font-medium">
</Label>
<Input
id="groupTitle"
value={localValues.title}
onChange={(e) => setLocalValues((prev) => ({ ...prev, title: e.target.value }))}
placeholder="그룹 제목"
className="mt-1"
/>
</div>
</div>
</>
)}
</div>
</div>
);
};
export default PropertiesPanel;

View File

@ -0,0 +1,224 @@
"use client";
import React, { useState } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Database,
ChevronDown,
ChevronRight,
Type,
Hash,
Calendar,
CheckSquare,
List,
AlignLeft,
Code,
Building,
File,
Search,
} from "lucide-react";
import { TableInfo, WebType } from "@/types/screen";
interface TablesPanelProps {
tables: TableInfo[];
searchTerm: string;
onSearchChange: (term: string) => void;
onDragStart: (e: React.DragEvent, table: TableInfo, column?: any) => void;
selectedTableName?: string;
}
// 위젯 타입별 아이콘
const getWidgetIcon = (widgetType: WebType) => {
switch (widgetType) {
case "text":
case "email":
case "tel":
return <Type className="h-3 w-3 text-blue-600" />;
case "number":
case "decimal":
return <Hash className="h-3 w-3 text-green-600" />;
case "date":
case "datetime":
return <Calendar className="h-3 w-3 text-purple-600" />;
case "select":
case "dropdown":
return <List className="h-3 w-3 text-orange-600" />;
case "textarea":
case "text_area":
return <AlignLeft className="h-3 w-3 text-indigo-600" />;
case "boolean":
case "checkbox":
return <CheckSquare className="h-3 w-3 text-blue-600" />;
case "code":
return <Code className="h-3 w-3 text-gray-600" />;
case "entity":
return <Building className="h-3 w-3 text-cyan-600" />;
case "file":
return <File className="h-3 w-3 text-yellow-600" />;
default:
return <Type className="h-3 w-3 text-gray-500" />;
}
};
export const TablesPanel: React.FC<TablesPanelProps> = ({
tables,
searchTerm,
onSearchChange,
onDragStart,
selectedTableName,
}) => {
const [expandedTables, setExpandedTables] = useState<Set<string>>(new Set());
const toggleTable = (tableName: string) => {
const newExpanded = new Set(expandedTables);
if (newExpanded.has(tableName)) {
newExpanded.delete(tableName);
} else {
newExpanded.add(tableName);
}
setExpandedTables(newExpanded);
};
const filteredTables = tables.filter(
(table) =>
table.tableName.toLowerCase().includes(searchTerm.toLowerCase()) ||
table.columns.some((col) => col.columnName.toLowerCase().includes(searchTerm.toLowerCase())),
);
return (
<div className="flex h-full flex-col">
{/* 헤더 */}
<div className="border-b border-gray-200 p-4">
{selectedTableName && (
<div className="mb-3 rounded-md bg-blue-50 p-3">
<div className="text-sm font-medium text-blue-900"> </div>
<div className="mt-1 flex items-center space-x-2">
<Database className="h-3 w-3 text-blue-600" />
<span className="font-mono text-xs text-blue-800">{selectedTableName}</span>
</div>
</div>
)}
{/* 검색 */}
<div className="relative">
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform text-gray-400" />
<input
type="text"
placeholder="테이블명, 컬럼명으로 검색..."
value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)}
className="w-full rounded-md border border-gray-300 py-2 pr-3 pl-10 focus:border-transparent focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="mt-2 text-xs text-gray-600"> {filteredTables.length} </div>
</div>
{/* 테이블 목록 */}
<div className="scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-gray-100 flex-1 overflow-y-auto">
<div className="space-y-1 p-2">
{filteredTables.map((table) => {
const isExpanded = expandedTables.has(table.tableName);
return (
<div key={table.tableName} className="rounded-md border border-gray-200">
{/* 테이블 헤더 */}
<div
className="flex cursor-pointer items-center justify-between p-3 hover:bg-gray-50"
onClick={() => toggleTable(table.tableName)}
>
<div className="flex flex-1 items-center space-x-2">
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-gray-500" />
) : (
<ChevronRight className="h-4 w-4 text-gray-500" />
)}
<Database className="h-4 w-4 text-blue-600" />
<div className="flex-1">
<div className="text-sm font-medium">{table.tableName}</div>
<div className="text-xs text-gray-500">{table.columns.length} </div>
</div>
</div>
<Button
size="sm"
variant="ghost"
draggable
onDragStart={(e) => onDragStart(e, table)}
className="ml-2 text-xs"
>
</Button>
</div>
{/* 컬럼 목록 */}
{isExpanded && (
<div className="border-t border-gray-200 bg-gray-50">
<div
className={`${
table.columns.length > 8
? "scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-gray-100 max-h-64 overflow-y-auto"
: ""
}`}
style={{
scrollbarWidth: "thin",
scrollbarColor: "#cbd5e1 #f1f5f9",
}}
>
{table.columns.map((column, index) => (
<div
key={column.columnName}
className={`flex cursor-pointer items-center justify-between p-2 hover:bg-white ${
index < table.columns.length - 1 ? "border-b border-gray-100" : ""
}`}
draggable
onDragStart={(e) => onDragStart(e, table, column)}
>
<div className="flex flex-1 items-center space-x-2">
{getWidgetIcon(column.widgetType)}
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{column.columnName}</div>
<div className="truncate text-xs text-gray-500">{column.dataType}</div>
</div>
</div>
<div className="flex flex-shrink-0 items-center space-x-1">
<Badge variant="secondary" className="text-xs">
{column.widgetType}
</Badge>
{column.required && (
<Badge variant="destructive" className="text-xs">
</Badge>
)}
</div>
</div>
))}
{/* 컬럼 수가 많을 때 안내 메시지 */}
{table.columns.length > 8 && (
<div className="sticky bottom-0 bg-gray-100 p-2 text-center">
<div className="text-xs text-gray-600">
📜 {table.columns.length} ( )
</div>
</div>
)}
</div>
</div>
)}
</div>
);
})}
</div>
</div>
{/* 푸터 */}
<div className="border-t border-gray-200 bg-gray-50 p-3">
<div className="text-xs text-gray-600">💡 </div>
</div>
</div>
);
};
export default TablesPanel;

View File

@ -0,0 +1,139 @@
"use client";
import { useState, useCallback, useEffect } from "react";
export interface PanelState {
isOpen: boolean;
position: { x: number; y: number };
size: { width: number; height: number };
}
export interface PanelConfig {
id: string;
title: string;
defaultPosition: "left" | "right" | "top" | "bottom";
defaultWidth: number;
defaultHeight: number;
shortcutKey?: string;
}
export const usePanelState = (panels: PanelConfig[]) => {
const [panelStates, setPanelStates] = useState<Record<string, PanelState>>(() => {
const initialStates: Record<string, PanelState> = {};
panels.forEach((panel) => {
initialStates[panel.id] = {
isOpen: false,
position: { x: 0, y: 0 },
size: { width: panel.defaultWidth, height: panel.defaultHeight },
};
});
return initialStates;
});
// 패널 토글
const togglePanel = useCallback((panelId: string) => {
setPanelStates((prev) => ({
...prev,
[panelId]: {
...prev[panelId],
isOpen: !prev[panelId]?.isOpen,
},
}));
}, []);
// 패널 열기
const openPanel = useCallback((panelId: string) => {
setPanelStates((prev) => ({
...prev,
[panelId]: {
...prev[panelId],
isOpen: true,
},
}));
}, []);
// 패널 닫기
const closePanel = useCallback((panelId: string) => {
setPanelStates((prev) => ({
...prev,
[panelId]: {
...prev[panelId],
isOpen: false,
},
}));
}, []);
// 모든 패널 닫기
const closeAllPanels = useCallback(() => {
setPanelStates((prev) => {
const newStates = { ...prev };
Object.keys(newStates).forEach((panelId) => {
newStates[panelId] = {
...newStates[panelId],
isOpen: false,
};
});
return newStates;
});
}, []);
// 패널 위치 업데이트
const updatePanelPosition = useCallback((panelId: string, position: { x: number; y: number }) => {
setPanelStates((prev) => ({
...prev,
[panelId]: {
...prev[panelId],
position,
},
}));
}, []);
// 패널 크기 업데이트
const updatePanelSize = useCallback((panelId: string, size: { width: number; height: number }) => {
setPanelStates((prev) => ({
...prev,
[panelId]: {
...prev[panelId],
size,
},
}));
}, []);
// 키보드 단축키 처리
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Esc 키로 모든 패널 닫기
if (e.key === "Escape") {
closeAllPanels();
return;
}
// 단축키 처리
panels.forEach((panel) => {
if (panel.shortcutKey && e.key.toLowerCase() === panel.shortcutKey.toLowerCase()) {
// Ctrl/Cmd 키와 함께 사용
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
togglePanel(panel.id);
}
}
});
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [panels, togglePanel, closeAllPanels]);
return {
panelStates,
togglePanel,
openPanel,
closePanel,
closeAllPanels,
updatePanelPosition,
updatePanelSize,
};
};

View File

@ -75,12 +75,28 @@ export function snapSizeToGrid(size: Size, gridInfo: GridInfo, gridSettings: Gri
const { gap } = gridSettings;
// 격자 단위로 너비 계산
const gridColumns = Math.max(1, Math.round(size.width / (columnWidth + gap)));
// 컴포넌트가 차지하는 컬럼 수를 올바르게 계산
let gridColumns = 1;
// 현재 너비에서 가장 가까운 격자 컬럼 수 찾기
for (let cols = 1; cols <= gridSettings.columns; cols++) {
const targetWidth = cols * columnWidth + (cols - 1) * gap;
if (size.width <= targetWidth + (columnWidth + gap) / 2) {
gridColumns = cols;
break;
}
gridColumns = cols;
}
const snappedWidth = gridColumns * columnWidth + (gridColumns - 1) * gap;
// 높이는 20px 단위로 스냅
const snappedHeight = Math.max(40, Math.round(size.height / 20) * 20);
console.log(
`📏 크기 스냅: ${size.width}px → ${snappedWidth}px (${gridColumns}컬럼, 컬럼너비:${columnWidth}px, 간격:${gap}px)`,
);
return {
width: Math.max(columnWidth, snappedWidth),
height: snappedHeight,

View File

@ -120,7 +120,7 @@ export interface ComponentStyle {
export interface BaseComponent {
id: string;
type: ComponentType;
position: { x: number; y: number };
position: Position;
size: { width: number; height: number };
parentId?: string;
style?: ComponentStyle; // 스타일 속성 추가
@ -199,6 +199,9 @@ export interface GridSettings {
gap: number; // 기본값: 16px
padding: number; // 기본값: 16px
snapToGrid?: boolean; // 격자에 맞춤 여부 (기본값: true)
showGrid?: boolean; // 격자 표시 여부 (기본값: true)
gridColor?: string; // 격자 색상 (기본값: #d1d5db)
gridOpacity?: number; // 격자 투명도 (기본값: 0.5)
}
// 유효성 검증 규칙
@ -291,8 +294,8 @@ export interface DropZone {
export interface GroupState {
isGrouping: boolean;
selectedComponents: string[];
groupTarget: string | null;
groupMode: "create" | "add" | "remove" | "ungroup";
groupTarget?: string | null;
groupMode?: "create" | "add" | "remove" | "ungroup";
groupTitle?: string;
groupStyle?: ComponentStyle;
}
@ -313,7 +316,9 @@ export interface ColumnInfo {
columnLabel?: string;
dataType: string;
webType?: WebType;
widgetType?: WebType; // 프론트엔드에서 사용하는 필드 (webType과 동일)
isNullable: string;
required?: boolean; // isNullable에서 변환된 필드
columnDefault?: string;
characterMaximumLength?: number;
numericPrecision?: number;