diff --git a/backend-node/src/controllers/screenManagementController.ts b/backend-node/src/controllers/screenManagementController.ts index 7cec9a8a..acbfbfcc 100644 --- a/backend-node/src/controllers/screenManagementController.ts +++ b/backend-node/src/controllers/screenManagementController.ts @@ -108,7 +108,7 @@ export const deleteScreen = async ( } }; -// 테이블 목록 조회 +// 테이블 목록 조회 (모든 테이블) export const getTables = async (req: AuthenticatedRequest, res: Response) => { try { const { companyCode } = req.user as any; @@ -122,6 +122,46 @@ export const getTables = async (req: AuthenticatedRequest, res: Response) => { } }; +// 특정 테이블 정보 조회 (최적화된 단일 테이블 조회) +export const getTableInfo = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { tableName } = req.params; + const { companyCode } = req.user as any; + + if (!tableName) { + res.status(400).json({ + success: false, + message: "테이블명이 필요합니다.", + }); + return; + } + + console.log(`=== 테이블 정보 조회 API 호출: ${tableName} ===`); + const tableInfo = await screenManagementService.getTableInfo( + tableName, + companyCode + ); + + if (!tableInfo) { + res.status(404).json({ + success: false, + message: `테이블 '${tableName}'을 찾을 수 없습니다.`, + }); + return; + } + + res.json({ success: true, data: tableInfo }); + } catch (error) { + console.error("테이블 정보 조회 실패:", error); + res + .status(500) + .json({ success: false, message: "테이블 정보 조회에 실패했습니다." }); + } +}; + // 테이블 컬럼 정보 조회 export const getTableColumns = async ( req: AuthenticatedRequest, diff --git a/backend-node/src/routes/screenManagementRoutes.ts b/backend-node/src/routes/screenManagementRoutes.ts index 6b7d3dcb..7e2dfd72 100644 --- a/backend-node/src/routes/screenManagementRoutes.ts +++ b/backend-node/src/routes/screenManagementRoutes.ts @@ -7,6 +7,7 @@ import { updateScreen, deleteScreen, getTables, + getTableInfo, getTableColumns, saveLayout, getLayout, @@ -33,6 +34,7 @@ router.get("/generate-screen-code/:companyCode", generateScreenCode); // 테이블 관리 router.get("/tables", getTables); +router.get("/tables/:tableName", getTableInfo); // 특정 테이블 정보 조회 (최적화) router.get("/tables/:tableName/columns", getTableColumns); // 레이아웃 관리 diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 80ebfe31..627bafff 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -205,7 +205,7 @@ export class ScreenManagementService { // ======================================== /** - * 테이블 목록 조회 + * 테이블 목록 조회 (모든 테이블) */ async getTables(companyCode: string): Promise { try { @@ -242,6 +242,54 @@ export class ScreenManagementService { } } + /** + * 특정 테이블 정보 조회 (최적화된 단일 테이블 조회) + */ + async getTableInfo( + tableName: string, + companyCode: string + ): Promise { + try { + console.log(`=== 단일 테이블 조회 시작: ${tableName} ===`); + + // 테이블 존재 여부 확인 + const tableExists = await prisma.$queryRaw>` + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_type = 'BASE TABLE' + AND table_name = ${tableName} + `; + + if (tableExists.length === 0) { + console.log(`테이블 ${tableName}이 존재하지 않습니다.`); + return null; + } + + // 해당 테이블의 컬럼 정보 조회 + const columns = await this.getTableColumns(tableName, companyCode); + + if (columns.length === 0) { + console.log(`테이블 ${tableName}의 컬럼 정보가 없습니다.`); + return null; + } + + const tableInfo: TableInfo = { + tableName: tableName, + tableLabel: this.getTableLabel(tableName), + columns: columns, + }; + + console.log( + `단일 테이블 조회 완료: ${tableName}, 컬럼 ${columns.length}개` + ); + return tableInfo; + } catch (error) { + console.error(`테이블 ${tableName} 조회 실패:`, error); + throw new Error(`테이블 ${tableName} 정보를 조회할 수 없습니다.`); + } + } + /** * 테이블 컬럼 정보 조회 */ diff --git a/frontend/components/screen/GridControls.tsx b/frontend/components/screen/GridControls.tsx new file mode 100644 index 00000000..4b0ca8d7 --- /dev/null +++ b/frontend/components/screen/GridControls.tsx @@ -0,0 +1,181 @@ +"use client"; + +import { useState } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Slider } from "@/components/ui/slider"; +import { Grid, Settings, RotateCcw } from "lucide-react"; + +interface GridSettings { + columns: number; + gap: number; + padding: number; + snapToGrid: boolean; +} + +interface GridControlsProps { + gridSettings: GridSettings; + onGridSettingsChange: (settings: GridSettings) => void; + className?: string; +} + +export default function GridControls({ gridSettings, onGridSettingsChange, className }: GridControlsProps) { + const [localSettings, setLocalSettings] = useState(gridSettings); + + const handleSettingChange = (key: keyof GridSettings, value: number | boolean) => { + const newSettings = { ...localSettings, [key]: value }; + setLocalSettings(newSettings); + onGridSettingsChange(newSettings); + }; + + const resetToDefault = () => { + const defaultSettings: GridSettings = { + columns: 12, + gap: 16, + padding: 16, + snapToGrid: true, + }; + setLocalSettings(defaultSettings); + onGridSettingsChange(defaultSettings); + }; + + return ( + + + + + 격자 설정 + + + + {/* 격자 열 개수 */} +
+ +
+ handleSettingChange("columns", value[0])} + className="flex-1" + /> + handleSettingChange("columns", parseInt(e.target.value) || 12)} + className="w-16 text-xs" + /> +
+
현재: {localSettings.columns}열
+
+ + {/* 격자 간격 */} +
+ +
+ handleSettingChange("gap", value[0])} + className="flex-1" + /> + handleSettingChange("gap", parseInt(e.target.value) || 16)} + className="w-16 text-xs" + /> +
+
+ + {/* 여백 */} +
+ +
+ handleSettingChange("padding", value[0])} + className="flex-1" + /> + handleSettingChange("padding", parseInt(e.target.value) || 16)} + className="w-16 text-xs" + /> +
+
+ + {/* 격자 스냅 */} +
+ + +
+ + {/* 격자 변경 안내 */} +
+
💡 격자 변경 시 자동 조정
+
+ 격자 설정을 변경하면 기존 컴포넌트들이 새 격자에 맞춰 자동으로 위치와 크기가 조정됩니다. +
+
+ + {/* 격자 미리보기 */} +
+ +
+
+ {Array.from({ length: localSettings.columns }).map((_, i) => ( +
+ ))} +
+
+
+ + {/* 초기화 버튼 */} + + + + ); +} diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index 5508e9dd..724ab7c7 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -7,7 +7,6 @@ import { Separator } from "@/components/ui/separator"; import { Palette, - Grid3X3, Type, Calendar, Hash, @@ -38,6 +37,7 @@ import { WebType, TableInfo, GroupComponent, + Position, } from "@/types/screen"; import { generateComponentId } from "@/lib/utils/generateId"; import { @@ -47,7 +47,15 @@ import { restoreAbsolutePositions, getGroupChildren, } from "@/lib/utils/groupingUtils"; +import { + calculateGridInfo, + snapToGrid, + snapSizeToGrid, + generateGridLines, + GridSettings as GridUtilSettings, +} from "@/lib/utils/gridUtils"; import { GroupingToolbar } from "./GroupingToolbar"; +import GridControls from "./GridControls"; import { screenApi } from "@/lib/api/screen"; import { toast } from "sonner"; @@ -65,7 +73,7 @@ interface ScreenDesignerProps { export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenDesignerProps) { const [layout, setLayout] = useState({ components: [], - gridSettings: { columns: 12, gap: 16, padding: 16 }, + gridSettings: { columns: 12, gap: 16, padding: 16, snapToGrid: true }, }); const [isSaving, setIsSaving] = useState(false); const [isLoading, setIsLoading] = useState(false); @@ -76,7 +84,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD const [history, setHistory] = useState([ { components: [], - gridSettings: { columns: 12, gap: 16, padding: 16 }, + gridSettings: { columns: 12, gap: 16, padding: 16, snapToGrid: true }, }, ]); const [historyIndex, setHistoryIndex] = useState(0); @@ -143,6 +151,95 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD // 그룹 생성 다이얼로그 상태 const [showGroupCreateDialog, setShowGroupCreateDialog] = useState(false); + // 캔버스 컨테이너 참조 + const canvasRef = useRef(null); + + // 격자 정보 계산 + const gridInfo = useMemo(() => { + if (!layout.gridSettings) return null; + + // canvasRef가 없거나 크기가 0인 경우 기본값 사용 + let width = 800; + let height = 600; + + if (canvasRef.current) { + const rect = canvasRef.current.getBoundingClientRect(); + width = Math.max(rect.width || 800, 800); + height = Math.max(rect.height || 600, 600); + } + + return calculateGridInfo(width, height, layout.gridSettings as GridUtilSettings); + }, [layout.gridSettings]); + + // 격자 설정 변경 핸들러 + const handleGridSettingsChange = useCallback( + (newGridSettings: GridUtilSettings) => { + let updatedComponents = layout.components; + + // 격자 스냅이 활성화되어 있고 격자 정보가 있으며 컴포넌트가 있는 경우 기존 컴포넌트들을 새 격자에 맞춤 + if (newGridSettings.snapToGrid && gridInfo && layout.components.length > 0) { + // 현재 캔버스 크기 가져오기 + let canvasWidth = 800; + let canvasHeight = 600; + + if (canvasRef.current) { + const rect = canvasRef.current.getBoundingClientRect(); + canvasWidth = Math.max(rect.width || 800, 800); + canvasHeight = Math.max(rect.height || 600, 600); + } + + const newGridInfo = calculateGridInfo(canvasWidth, canvasHeight, newGridSettings); + + updatedComponents = layout.components.map((comp) => { + // 그룹의 자식 컴포넌트는 건드리지 않음 (그룹에서 처리) + if (comp.parentId) return comp; + + // 기존 격자에서의 상대적 위치 계산 (격자 컬럼 단위) + const oldGridInfo = gridInfo; + const oldColumnWidth = oldGridInfo.columnWidth; + const oldGap = layout.gridSettings?.gap || 16; + const oldPadding = layout.gridSettings?.padding || 16; + + // 기존 위치를 격자 컬럼/행 단위로 변환 + const oldGridX = Math.round((comp.position.x - oldPadding) / (oldColumnWidth + oldGap)); + const oldGridY = Math.round((comp.position.y - oldPadding) / 20); // 20px 단위 + + // 기존 크기를 격자 컬럼 단위로 변환 + const oldGridColumns = Math.max(1, Math.round((comp.size.width + oldGap) / (oldColumnWidth + oldGap))); + const oldGridRows = Math.max(2, Math.round(comp.size.height / 20)); // 20px 단위 + + // 새 격자에서의 위치와 크기 계산 + const newColumnWidth = newGridInfo.columnWidth; + const newGap = newGridSettings.gap; + const newPadding = newGridSettings.padding; + + // 새 위치 계산 (격자 비율 유지) + const newX = newPadding + oldGridX * (newColumnWidth + newGap); + const newY = newPadding + oldGridY * 20; + + // 새 크기 계산 (격자 비율 유지) + const newWidth = oldGridColumns * newColumnWidth + (oldGridColumns - 1) * newGap; + const newHeight = oldGridRows * 20; + + return { + ...comp, + position: { x: newX, y: newY, z: comp.position.z || 1 }, + size: { width: newWidth, height: newHeight }, + }; + }); + } + + const newLayout = { + ...layout, + components: updatedComponents, + gridSettings: newGridSettings, + }; + setLayout(newLayout); + saveToHistory(newLayout); + }, + [layout, saveToHistory, gridInfo], + ); + const [tables, setTables] = useState([]); const [expandedTables, setExpandedTables] = useState>(new Set()); @@ -277,39 +374,56 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD setSelectionState({ isSelecting: false, start: { x: 0, y: 0 }, current: { x: 0, y: 0 } }); }, [selectionState, layout.components, getAbsolutePosition]); - // 테이블 데이터 로드 (실제로는 API에서 가져와야 함) + // 선택된 화면의 테이블만 로드 (최적화된 API 사용) useEffect(() => { - const fetchTables = async () => { + const fetchScreenTable = async () => { + if (!selectedScreen?.tableName) { + setTables([]); + return; + } + try { - const response = await fetch("http://localhost:8080/api/screen-management/tables", { + console.log(`=== 테이블 정보 조회 시작: ${selectedScreen.tableName} ===`); + const startTime = performance.now(); + + // 최적화된 단일 테이블 조회 API 사용 + const response = await fetch(`http://localhost:8080/api/screen-management/tables/${selectedScreen.tableName}`, { headers: { Authorization: `Bearer ${localStorage.getItem("authToken")}`, }, }); + const endTime = performance.now(); + console.log(`테이블 조회 완료: ${(endTime - startTime).toFixed(2)}ms`); + if (response.ok) { const data = await response.json(); - if (data.success) { - setTables(data.data); + if (data.success && data.data) { + setTables([data.data]); + console.log(`테이블 ${selectedScreen.tableName} 로드 완료, 컬럼 ${data.data.columns.length}개`); } else { console.error("테이블 조회 실패:", data.message); - // 임시 데이터로 폴백 - setTables(getMockTables()); + // 선택된 화면의 테이블에 대한 임시 데이터 생성 + setTables([createMockTableForScreen(selectedScreen.tableName)]); } + } else if (response.status === 404) { + console.warn(`테이블 ${selectedScreen.tableName}을 찾을 수 없습니다.`); + // 테이블이 존재하지 않는 경우 임시 데이터 생성 + setTables([createMockTableForScreen(selectedScreen.tableName)]); } else { console.error("테이블 조회 실패:", response.status); - // 임시 데이터로 폴백 - setTables(getMockTables()); + // 선택된 화면의 테이블에 대한 임시 데이터 생성 + setTables([createMockTableForScreen(selectedScreen.tableName)]); } } catch (error) { console.error("테이블 조회 중 오류:", error); - // 임시 데이터로 폴백 - setTables(getMockTables()); + // 선택된 화면의 테이블에 대한 임시 데이터 생성 + setTables([createMockTableForScreen(selectedScreen.tableName)]); } }; - fetchTables(); - }, []); + fetchScreenTable(); + }, [selectedScreen?.tableName]); // 검색된 테이블 필터링 const filteredTables = useMemo(() => { @@ -351,194 +465,60 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD }; // 임시 테이블 데이터 (API 실패 시 사용) - const getMockTables = (): TableInfo[] => [ - { - tableName: "user_info", - tableLabel: "사용자 정보", - columns: [ - { - tableName: "user_info", - columnName: "user_id", - columnLabel: "사용자 ID", - webType: "text", - dataType: "VARCHAR", - isNullable: "NO", - }, - { - tableName: "user_info", - columnName: "user_name", - columnLabel: "사용자명", - webType: "text", - dataType: "VARCHAR", - isNullable: "NO", - }, - { - tableName: "user_info", - columnName: "email", - columnLabel: "이메일", - webType: "email", - dataType: "VARCHAR", - isNullable: "YES", - }, - { - tableName: "user_info", - columnName: "phone", - columnLabel: "전화번호", - webType: "tel", - dataType: "VARCHAR", - isNullable: "YES", - }, - { - tableName: "user_info", - columnName: "birth_date", - columnLabel: "생년월일", - webType: "date", - dataType: "DATE", - isNullable: "YES", - }, - { - tableName: "user_info", - columnName: "is_active", - columnLabel: "활성화", - webType: "checkbox", - dataType: "BOOLEAN", - isNullable: "NO", - }, - { - tableName: "user_info", - columnName: "profile_code", - columnLabel: "프로필 코드", - webType: "code", - dataType: "TEXT", - isNullable: "YES", - }, - { - tableName: "user_info", - columnName: "department", - columnLabel: "부서", - webType: "entity", - dataType: "VARCHAR", - isNullable: "YES", - }, - { - tableName: "user_info", - columnName: "profile_image", - columnLabel: "프로필 이미지", - webType: "file", - dataType: "VARCHAR", - isNullable: "YES", - }, - ], - }, - { - tableName: "product_info", - tableLabel: "제품 정보", - columns: [ - { - tableName: "product_info", - columnName: "product_id", - columnLabel: "제품 ID", - webType: "text", - dataType: "VARCHAR", - isNullable: "NO", - }, - { - tableName: "product_info", - columnName: "product_name", - columnLabel: "제품명", - webType: "text", - dataType: "VARCHAR", - isNullable: "NO", - }, - { - tableName: "product_info", - columnName: "category", - columnLabel: "카테고리", - webType: "select", - dataType: "VARCHAR", - isNullable: "YES", - }, - { - tableName: "product_info", - columnName: "price", - columnLabel: "가격", - webType: "number", - dataType: "DECIMAL", - isNullable: "YES", - }, - { - tableName: "product_info", - columnName: "description", - columnLabel: "설명", - webType: "textarea", - dataType: "TEXT", - isNullable: "YES", - }, - { - tableName: "product_info", - columnName: "created_date", - columnLabel: "생성일", - webType: "date", - dataType: "TIMESTAMP", - isNullable: "NO", - }, - ], - }, - { - tableName: "order_info", - tableLabel: "주문 정보", - columns: [ - { - tableName: "order_info", - columnName: "order_id", - columnLabel: "주문 ID", - webType: "text", - dataType: "VARCHAR", - isNullable: "NO", - }, - { - tableName: "order_info", - columnName: "customer_name", - columnLabel: "고객명", - webType: "text", - dataType: "VARCHAR", - isNullable: "NO", - }, - { - tableName: "order_info", - columnName: "order_date", - columnLabel: "주문일", - webType: "date", - dataType: "DATE", - isNullable: "NO", - }, - { - tableName: "order_info", - columnName: "total_amount", - columnLabel: "총 금액", - webType: "number", - dataType: "DECIMAL", - isNullable: "NO", - }, - { - tableName: "order_info", - columnName: "status", - columnLabel: "상태", - webType: "select", - dataType: "VARCHAR", - isNullable: "NO", - }, - { - tableName: "order_info", - columnName: "notes", - columnLabel: "비고", - webType: "textarea", - dataType: "TEXT", - isNullable: "YES", - }, - ], - }, - ]; + // 사용하지 않는 getMockTables 함수 제거됨 + + // 특정 테이블에 대한 임시 데이터 생성 + const createMockTableForScreen = (tableName: string): TableInfo => { + // 기본 컬럼들 생성 + const baseColumns = [ + { + tableName, + columnName: "id", + columnLabel: "ID", + webType: "number" as WebType, + dataType: "BIGINT", + isNullable: "NO", + }, + { + tableName, + columnName: "name", + columnLabel: "이름", + webType: "text" as WebType, + dataType: "VARCHAR", + isNullable: "NO", + }, + { + tableName, + columnName: "description", + columnLabel: "설명", + webType: "textarea" as WebType, + dataType: "TEXT", + isNullable: "YES", + }, + { + tableName, + columnName: "created_date", + columnLabel: "생성일", + webType: "date" as WebType, + dataType: "TIMESTAMP", + isNullable: "NO", + }, + { + tableName, + columnName: "updated_date", + columnLabel: "수정일", + webType: "date" as WebType, + dataType: "TIMESTAMP", + isNullable: "YES", + }, + ]; + + return { + tableName, + tableLabel: `${tableName} (임시)`, + columns: baseColumns, + }; + }; // 테이블 확장/축소 토글 const toggleTableExpansion = useCallback((tableName: string) => { @@ -786,10 +766,9 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD const absoluteChildren = groupChildren.map((child) => ({ ...child, position: { - ...child.position, x: child.position.x + group.position.x, y: child.position.y + group.position.y, - z: child.position.z || 1, + z: (child.position as any).z || 1, }, parentId: undefined, })); @@ -861,6 +840,16 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD } current[pathParts[pathParts.length - 1]] = value; + // 크기 변경 시 격자 스냅 적용 + if ( + (propertyPath === "size.width" || propertyPath === "size.height") && + layout.gridSettings?.snapToGrid && + gridInfo + ) { + const snappedSize = snapSizeToGrid(newComp.size, gridInfo, layout.gridSettings as GridUtilSettings); + newComp.size = snappedSize; + } + return newComp; } return comp; @@ -874,7 +863,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD if (updated) setSelectedComponent(updated); } }, - [layout, saveToHistory, selectedComponent], + [layout, saveToHistory, selectedComponent, gridInfo], ); // 그룹 생성 함수 @@ -986,6 +975,13 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD const savedLayout = await screenApi.getLayout(selectedScreen.screenId); if (savedLayout && savedLayout.components) { + // 격자 설정이 없는 경우 기본값 추가 + if (!savedLayout.gridSettings) { + savedLayout.gridSettings = { columns: 12, gap: 16, padding: 16, snapToGrid: true }; + } else if (savedLayout.gridSettings.snapToGrid === undefined) { + savedLayout.gridSettings.snapToGrid = true; + } + setLayout(savedLayout); // 히스토리 초기화 setHistory([savedLayout]); @@ -996,7 +992,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD // 저장된 레이아웃이 없는 경우 기본 레이아웃 유지 const defaultLayout = { components: [], - gridSettings: { columns: 12, gap: 16, padding: 16 }, + gridSettings: { columns: 12, gap: 16, padding: 16, snapToGrid: true }, }; setLayout(defaultLayout); setHistory([defaultLayout]); @@ -1008,7 +1004,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD // 에러 시에도 기본 레이아웃으로 초기화 const defaultLayout = { components: [], - gridSettings: { columns: 12, gap: 16, padding: 16 }, + gridSettings: { columns: 12, gap: 16, padding: 16, snapToGrid: true }, }; setLayout(defaultLayout); setHistory([defaultLayout]); @@ -1027,8 +1023,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD } }, [selectedScreen, loadLayout]); - // 캔버스 참조 (좌표 계산 정확도 향상) - const canvasRef = useRef(null); + // 스크롤 컨테이너 참조 (좌표 계산 정확도 향상) const scrollContainerRef = useRef(null); // 드래그 시작 (새 컴포넌트 추가) @@ -1160,11 +1155,26 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD ...layout, components: layout.components.map((comp) => { if (data.selectedComponentIds.includes(comp.id)) { + let newX = comp.position.x + deltaX; + let newY = comp.position.y + deltaY; + + // 격자 스냅 적용 + if (layout.gridSettings?.snapToGrid && gridInfo) { + const snappedPosition = snapToGrid( + { x: newX, y: newY, z: comp.position.z || 1 } as Required, + gridInfo, + layout.gridSettings as GridUtilSettings, + ); + newX = snappedPosition.x; + newY = snappedPosition.y; + } + return { ...comp, position: { - x: comp.position.x + deltaX, - y: comp.position.y + deltaY, + x: newX, + y: newY, + z: comp.position.z || 1, }, }; } @@ -1175,12 +1185,24 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD saveToHistory(newLayout); } else { // 단일 드래그 처리 - const x = mouseX - dragState.grabOffset.x; - const y = mouseY - dragState.grabOffset.y; + let x = mouseX - dragState.grabOffset.x; + let y = mouseY - dragState.grabOffset.y; + + // 격자 스냅 적용 + if (layout.gridSettings?.snapToGrid && gridInfo) { + const snappedPosition = snapToGrid( + { x, y, z: 1 } as Required, + gridInfo, + layout.gridSettings as GridUtilSettings, + ); + x = snappedPosition.x; + y = snappedPosition.y; + } + const newLayout = { ...layout, components: layout.components.map((comp) => - comp.id === data.id ? { ...comp, position: { x, y } } : comp, + comp.id === data.id ? { ...comp, position: { x, y, z: comp.position.z || 1 } } : comp, ), }; setLayout(newLayout); @@ -1192,13 +1214,37 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD // 스크롤 오프셋 추가하여 컨텐츠 좌표로 변환 (상위 스크롤 컨테이너 기준) const scrollLeft = scrollContainerRef.current?.scrollLeft || 0; const scrollTop = scrollContainerRef.current?.scrollTop || 0; - const x = rect ? e.clientX - rect.left + scrollLeft : 0; - const y = rect ? e.clientY - rect.top + scrollTop : 0; + let x = rect ? e.clientX - rect.left + scrollLeft : 0; + let y = rect ? e.clientY - rect.top + scrollTop : 0; + + // 격자 스냅 적용 + if (layout.gridSettings?.snapToGrid && gridInfo) { + const snappedPosition = snapToGrid( + { x, y, z: 1 } as Required, + gridInfo, + layout.gridSettings as GridUtilSettings, + ); + x = snappedPosition.x; + y = snappedPosition.y; + } + + // 기본 크기를 격자에 맞춰 설정 + let defaultWidth = data.size?.width || 200; + const defaultHeight = data.size?.height || 100; + + if (layout.gridSettings?.snapToGrid && gridInfo) { + const { columnWidth } = gridInfo; + const { gap } = layout.gridSettings; + // 기본적으로 1컬럼 너비로 설정 + const gridColumns = 1; + defaultWidth = gridColumns * columnWidth + (gridColumns - 1) * gap; + } const newComponent: ComponentData = { ...data, id: generateComponentId(), - position: { x, y }, + position: { x, y, z: 1 }, + size: { width: defaultWidth, height: defaultHeight }, } as ComponentData; const newLayout = { @@ -1230,11 +1276,29 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD dragState.initialMouse.y, dragState.grabOffset.x, dragState.grabOffset.y, + gridInfo, ], ); // 드래그 종료 const endDrag = useCallback(() => { + // 격자 스냅 적용 + if (dragState.isDragging && dragState.draggedComponent && gridInfo && layout.gridSettings?.snapToGrid) { + const component = dragState.draggedComponent; + const snappedPosition = snapToGrid(dragState.currentPosition, gridInfo, layout.gridSettings as GridUtilSettings); + + // 스냅된 위치로 컴포넌트 업데이트 + if (snappedPosition.x !== dragState.currentPosition.x || snappedPosition.y !== dragState.currentPosition.y) { + const updatedComponents = layout.components.map((comp) => + comp.id === component.id ? { ...comp, position: snappedPosition } : comp, + ); + + const newLayout = { ...layout, components: updatedComponents }; + setLayout(newLayout); + saveToHistory(newLayout); + } + } + setDragState({ isDragging: false, draggedComponent: null, @@ -1245,7 +1309,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD initialMouse: { x: 0, y: 0 }, grabOffset: { x: 0, y: 0 }, }); - }, []); + }, [dragState, gridInfo, layout, saveToHistory]); // 컴포넌트 클릭 (선택) const handleComponentClick = useCallback( @@ -1472,7 +1536,19 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD {/* 좌측 사이드바 - 테이블 타입 */}
-

테이블 타입

+
+

테이블 타입

+ {selectedScreen && ( +
+
선택된 화면
+
{selectedScreen.screenName}
+
+ + {selectedScreen.tableName} +
+
+ )} +
{/* 검색 입력창 */}
@@ -1496,99 +1572,115 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD {/* 테이블 목록 */}
- {paginatedTables.map((table) => ( -
- {/* 테이블 헤더 */} -
- startDrag( - { - type: "container", - tableName: table.tableName, - label: table.tableLabel, - size: { width: 200, height: 80 }, // 픽셀 단위로 변경 - }, - e, - ) - } - > -
- -
-
{table.tableLabel}
-
{table.tableName}
-
-
- + {paginatedTables.length === 0 ? ( +
+
+ +

+ {selectedScreen ? "테이블 정보를 불러오는 중..." : "화면을 선택해주세요"} +

+

+ {selectedScreen + ? `${selectedScreen.tableName} 테이블의 컬럼 정보를 조회하고 있습니다.` + : "화면을 선택하면 해당 테이블의 컬럼 정보가 표시됩니다."} +

- - {/* 컬럼 목록 */} - {expandedTables.has(table.tableName) && ( -
- {table.columns.map((column) => ( -
{ - console.log("Drag start - column:", column.columnName, "webType:", column.webType); - const widgetType = getWidgetTypeFromWebType(column.webType || "text"); - console.log("Drag start - widgetType:", widgetType); - startDrag( - { - type: "widget", - tableName: table.tableName, - columnName: column.columnName, - widgetType: widgetType as WebType, - label: column.columnLabel || column.columnName, - size: { width: 150, height: 40 }, // 픽셀 단위로 변경 - }, - e, - ); - }} - > -
- {column.webType === "text" && } - {column.webType === "email" && } - {column.webType === "tel" && } - {column.webType === "number" && } - {column.webType === "decimal" && } - {column.webType === "date" && } - {column.webType === "datetime" && } - {column.webType === "select" && } - {column.webType === "dropdown" && } - {column.webType === "textarea" && } - {column.webType === "text_area" && } - {column.webType === "checkbox" && } - {column.webType === "boolean" && } - {column.webType === "radio" && } - {column.webType === "code" && } - {column.webType === "entity" && } - {column.webType === "file" && } -
-
-
{column.columnLabel || column.columnName}
-
{column.columnName}
-
-
- ))} -
- )}
- ))} + ) : ( + paginatedTables.map((table) => ( +
+ {/* 테이블 헤더 */} +
+ startDrag( + { + type: "container", + tableName: table.tableName, + label: table.tableLabel, + size: { width: 200, height: 80 }, // 픽셀 단위로 변경 + }, + e, + ) + } + > +
+ +
+
{table.tableLabel}
+
{table.tableName}
+
+
+ +
+ + {/* 컬럼 목록 */} + {expandedTables.has(table.tableName) && ( +
+ {table.columns.map((column) => ( +
{ + console.log("Drag start - column:", column.columnName, "webType:", column.webType); + const widgetType = getWidgetTypeFromWebType(column.webType || "text"); + console.log("Drag start - widgetType:", widgetType); + startDrag( + { + type: "widget", + tableName: table.tableName, + columnName: column.columnName, + widgetType: widgetType as WebType, + label: column.columnLabel || column.columnName, + size: { width: 150, height: 40 }, // 픽셀 단위로 변경 + }, + e, + ); + }} + > +
+ {column.webType === "text" && } + {column.webType === "email" && } + {column.webType === "tel" && } + {column.webType === "number" && } + {column.webType === "decimal" && } + {column.webType === "date" && } + {column.webType === "datetime" && } + {column.webType === "select" && } + {column.webType === "dropdown" && } + {column.webType === "textarea" && } + {column.webType === "text_area" && } + {column.webType === "checkbox" && } + {column.webType === "boolean" && } + {column.webType === "radio" && } + {column.webType === "code" && } + {column.webType === "entity" && } + {column.webType === "file" && } +
+
+
{column.columnLabel || column.columnName}
+
{column.columnName}
+
+
+ ))} +
+ )} +
+ )) + )}
{/* 페이징 컨트롤 */} @@ -1634,88 +1726,119 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD onMouseUp={handleMarqueeEnd} onContextMenu={handleCanvasContextMenu} > - {layout.components.length === 0 ? ( -
-
- -

빈 캔버스

-

좌측에서 테이블이나 컬럼을 드래그하여 배치하세요

+ {/* 항상 격자와 캔버스 표시 */} +
+ {/* 동적 그리드 가이드 */} +
+
+ {Array.from({ length: layout.gridSettings?.columns || 12 }).map((_, i) => ( +
+ ))}
-
- ) : ( -
- {/* 그리드 가이드 */} -
-
- {Array.from({ length: 12 }).map((_, i) => ( -
+ + {/* 격자 스냅이 활성화된 경우 추가 가이드라인 */} + {layout.gridSettings?.snapToGrid && gridInfo && ( +
+ {generateGridLines( + canvasRef.current?.clientWidth || 800, + canvasRef.current?.clientHeight || 600, + layout.gridSettings as GridUtilSettings, + ).verticalLines.map((x, i) => ( +
+ ))} + {generateGridLines( + canvasRef.current?.clientWidth || 800, + canvasRef.current?.clientHeight || 600, + layout.gridSettings as GridUtilSettings, + ).horizontalLines.map((y, i) => ( +
))}
-
- - {/* 마키 선택 사각형 */} - {selectionState.isSelecting && ( -
)} - - {/* 컴포넌트들 - 실시간 미리보기 */} - {layout.components - .filter((component) => !component.parentId) // 최상위 컴포넌트만 렌더링 - .map((component) => { - // 그룹 컴포넌트인 경우 자식 컴포넌트들 가져오기 - const children = - component.type === "group" - ? layout.components.filter((child) => child.parentId === component.id) - : []; - - return ( - handleComponentClick(component, e)} - onDragStart={(e) => startComponentDrag(component, e)} - onDragEnd={endDrag} - onGroupToggle={(groupId) => { - // 그룹 접기/펼치기 토글 - const groupComp = component as GroupComponent; - updateComponentProperty(groupId, "collapsed", !groupComp.collapsed); - }} - > - {children.map((child) => ( - handleComponentClick(child, e)} - onDragStart={(e) => startComponentDrag(child, e)} - onDragEnd={endDrag} - /> - ))} - - ); - })}
- )} + + {/* 마키 선택 사각형 */} + {selectionState.isSelecting && ( +
+ )} + + {/* 컴포넌트들 - 실시간 미리보기 */} + {layout.components + .filter((component) => !component.parentId) // 최상위 컴포넌트만 렌더링 + .map((component) => { + // 그룹 컴포넌트인 경우 자식 컴포넌트들 가져오기 + const children = + component.type === "group" + ? layout.components.filter((child) => child.parentId === component.id) + : []; + + return ( + handleComponentClick(component, e)} + onDragStart={(e) => startComponentDrag(component, e)} + onDragEnd={endDrag} + onGroupToggle={(groupId) => { + // 그룹 접기/펼치기 토글 + const groupComp = component as GroupComponent; + updateComponentProperty(groupId, "collapsed", !groupComp.collapsed); + }} + > + {children.map((child) => ( + handleComponentClick(child, e)} + onDragStart={(e) => startComponentDrag(child, e)} + onDragEnd={endDrag} + /> + ))} + + ); + })} +
{/* 우측: 컴포넌트 스타일 편집 */}
-
-

컴포넌트 속성

+
+ {/* 격자 설정 */} + + +

컴포넌트 속성

{selectedComponent ? (
@@ -1739,7 +1862,23 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD onChange={(e) => { const val = (e.target as HTMLInputElement).valueAsNumber; if (Number.isFinite(val)) { - updateComponentProperty(selectedComponent.id, "position.x", Math.round(val)); + let newX = Math.round(val); + + // 격자 스냅이 활성화된 경우 격자에 맞춤 + if (layout.gridSettings?.snapToGrid && gridInfo) { + const snappedPos = snapToGrid( + { + x: newX, + y: selectedComponent.position.y, + z: selectedComponent.position.z || 1, + } as Required, + gridInfo, + layout.gridSettings as GridUtilSettings, + ); + newX = snappedPos.x; + } + + updateComponentProperty(selectedComponent.id, "position.x", newX); } }} /> @@ -1754,7 +1893,23 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD onChange={(e) => { const val = (e.target as HTMLInputElement).valueAsNumber; if (Number.isFinite(val)) { - updateComponentProperty(selectedComponent.id, "position.y", Math.round(val)); + let newY = Math.round(val); + + // 격자 스냅이 활성화된 경우 격자에 맞춤 + if (layout.gridSettings?.snapToGrid && gridInfo) { + const snappedPos = snapToGrid( + { + x: selectedComponent.position.x, + y: newY, + z: selectedComponent.position.z || 1, + } as Required, + gridInfo, + layout.gridSettings as GridUtilSettings, + ); + newY = snappedPos.y; + } + + updateComponentProperty(selectedComponent.id, "position.y", newY); } }} /> @@ -1764,23 +1919,54 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD {/* 크기 속성 */}
- - { - const val = (e.target as HTMLInputElement).valueAsNumber; - if (Number.isFinite(val)) { - updateComponentProperty( - selectedComponent.id, - "size.width", - Math.max(20, Math.round(val)), - ); - } - }} - /> + + {layout.gridSettings?.snapToGrid && gridInfo ? ( + // 격자 스냅이 활성화된 경우 컬럼 단위로 조정 +
+ { + const { columnWidth } = gridInfo; + const { gap } = layout.gridSettings; + return Math.max( + 1, + Math.round((selectedComponent.size.width + gap) / (columnWidth + gap)), + ); + })()} + onChange={(e) => { + const gridColumns = Math.max( + 1, + Math.min(layout.gridSettings!.columns, parseInt(e.target.value) || 1), + ); + const { columnWidth } = gridInfo; + const { gap } = layout.gridSettings!; + const newWidth = gridColumns * columnWidth + (gridColumns - 1) * gap; + updateComponentProperty(selectedComponent.id, "size.width", newWidth); + }} + /> +
실제 너비: {selectedComponent.size.width}px
+
+ ) : ( + // 격자 스냅이 비활성화된 경우 픽셀 단위로 조정 + { + const val = (e.target as HTMLInputElement).valueAsNumber; + if (Number.isFinite(val)) { + const newWidth = Math.max(20, Math.round(val)); + updateComponentProperty(selectedComponent.id, "size.width", newWidth); + } + }} + /> + )}
@@ -1792,11 +1978,14 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD onChange={(e) => { const val = (e.target as HTMLInputElement).valueAsNumber; if (Number.isFinite(val)) { - updateComponentProperty( - selectedComponent.id, - "size.height", - Math.max(20, Math.round(val)), - ); + let newHeight = Math.max(20, Math.round(val)); + + // 격자 스냅이 활성화된 경우 20px 단위로 조정 + if (layout.gridSettings?.snapToGrid) { + newHeight = Math.max(40, Math.round(newHeight / 20) * 20); + } + + updateComponentProperty(selectedComponent.id, "size.height", newHeight); } }} /> diff --git a/frontend/components/ui/slider.tsx b/frontend/components/ui/slider.tsx new file mode 100644 index 00000000..9a7218c4 --- /dev/null +++ b/frontend/components/ui/slider.tsx @@ -0,0 +1,52 @@ +"use client"; + +import * as React from "react"; +import { cn } from "@/lib/utils"; + +interface SliderProps extends React.InputHTMLAttributes { + value?: number[]; + onValueChange?: (value: number[]) => void; + min?: number; + max?: number; + step?: number; + className?: string; +} + +const Slider = React.forwardRef( + ({ className, value = [0], onValueChange, min = 0, max = 100, step = 1, ...props }, ref) => { + const handleChange = (e: React.ChangeEvent) => { + const newValue = parseFloat(e.target.value); + onValueChange?.([newValue]); + }; + + return ( +
+ +
+ ); + }, +); + +Slider.displayName = "Slider"; + +export { Slider }; diff --git a/frontend/lib/utils/gridUtils.ts b/frontend/lib/utils/gridUtils.ts new file mode 100644 index 00000000..c064e3bc --- /dev/null +++ b/frontend/lib/utils/gridUtils.ts @@ -0,0 +1,166 @@ +import { Position, Size } from "@/types/screen"; + +export interface GridSettings { + columns: number; + gap: number; + padding: number; + snapToGrid: boolean; +} + +export interface GridInfo { + columnWidth: number; + totalWidth: number; + totalHeight: number; +} + +/** + * 격자 정보 계산 + */ +export function calculateGridInfo( + containerWidth: number, + containerHeight: number, + gridSettings: GridSettings, +): GridInfo { + const { columns, gap, padding } = gridSettings; + + // 사용 가능한 너비 계산 (패딩 제외) + const availableWidth = containerWidth - padding * 2; + + // 격자 간격을 고려한 컬럼 너비 계산 + const totalGaps = (columns - 1) * gap; + const columnWidth = (availableWidth - totalGaps) / columns; + + return { + columnWidth: Math.max(columnWidth, 50), // 최소 50px + totalWidth: containerWidth, + totalHeight: containerHeight, + }; +} + +/** + * 위치를 격자에 맞춤 + */ +export function snapToGrid(position: Position, gridInfo: GridInfo, gridSettings: GridSettings): Position { + if (!gridSettings.snapToGrid) { + return position; + } + + const { columnWidth } = gridInfo; + const { gap, padding } = gridSettings; + + // 격자 기준으로 위치 계산 + const gridX = Math.round((position.x - padding) / (columnWidth + gap)); + const gridY = Math.round((position.y - padding) / 20); // 20px 단위로 세로 스냅 + + // 실제 픽셀 위치로 변환 + const snappedX = Math.max(padding, padding + gridX * (columnWidth + gap)); + const snappedY = Math.max(padding, padding + gridY * 20); + + return { + x: snappedX, + y: snappedY, + z: position.z, + }; +} + +/** + * 크기를 격자에 맞춤 + */ +export function snapSizeToGrid(size: Size, gridInfo: GridInfo, gridSettings: GridSettings): Size { + if (!gridSettings.snapToGrid) { + return size; + } + + const { columnWidth } = gridInfo; + const { gap } = gridSettings; + + // 격자 단위로 너비 계산 + const gridColumns = Math.max(1, Math.round(size.width / (columnWidth + gap))); + const snappedWidth = gridColumns * columnWidth + (gridColumns - 1) * gap; + + // 높이는 20px 단위로 스냅 + const snappedHeight = Math.max(40, Math.round(size.height / 20) * 20); + + return { + width: Math.max(columnWidth, snappedWidth), + height: snappedHeight, + }; +} + +/** + * 격자 컬럼 수로 너비 계산 + */ +export function calculateWidthFromColumns(columns: number, gridInfo: GridInfo, gridSettings: GridSettings): number { + const { columnWidth } = gridInfo; + const { gap } = gridSettings; + + return columns * columnWidth + (columns - 1) * gap; +} + +/** + * 너비에서 격자 컬럼 수 계산 + */ +export function calculateColumnsFromWidth(width: number, gridInfo: GridInfo, gridSettings: GridSettings): number { + const { columnWidth } = gridInfo; + const { gap } = gridSettings; + + return Math.max(1, Math.round((width + gap) / (columnWidth + gap))); +} + +/** + * 격자 가이드라인 생성 + */ +export function generateGridLines( + containerWidth: number, + containerHeight: number, + gridSettings: GridSettings, +): { + verticalLines: number[]; + horizontalLines: number[]; +} { + const { columns, gap, padding } = gridSettings; + const gridInfo = calculateGridInfo(containerWidth, containerHeight, gridSettings); + const { columnWidth } = gridInfo; + + // 세로 격자선 (컬럼 경계) + const verticalLines: number[] = []; + for (let i = 0; i <= columns; i++) { + const x = padding + i * (columnWidth + gap) - gap / 2; + if (x >= padding && x <= containerWidth - padding) { + verticalLines.push(x); + } + } + + // 가로 격자선 (20px 단위) + const horizontalLines: number[] = []; + for (let y = padding; y < containerHeight; y += 20) { + horizontalLines.push(y); + } + + return { + verticalLines, + horizontalLines, + }; +} + +/** + * 컴포넌트가 격자 경계에 있는지 확인 + */ +export function isOnGridBoundary( + position: Position, + size: Size, + gridInfo: GridInfo, + gridSettings: GridSettings, + tolerance: number = 5, +): boolean { + const snappedPos = snapToGrid(position, gridInfo, gridSettings); + const snappedSize = snapSizeToGrid(size, gridInfo, gridSettings); + + const positionMatch = + Math.abs(position.x - snappedPos.x) <= tolerance && Math.abs(position.y - snappedPos.y) <= tolerance; + + const sizeMatch = + Math.abs(size.width - snappedSize.width) <= tolerance && Math.abs(size.height - snappedSize.height) <= tolerance; + + return positionMatch && sizeMatch; +} diff --git a/frontend/types/screen.ts b/frontend/types/screen.ts index bc831e16..d656425b 100644 --- a/frontend/types/screen.ts +++ b/frontend/types/screen.ts @@ -198,6 +198,7 @@ export interface GridSettings { columns: number; // 기본값: 12 gap: number; // 기본값: 16px padding: number; // 기본값: 16px + snapToGrid?: boolean; // 격자에 맞춤 여부 (기본값: true) } // 유효성 검증 규칙