격자에 맞게 컴포넌트 배치

This commit is contained in:
kjs 2025-09-02 11:16:40 +09:00
parent 7002384393
commit c3213b8a85
8 changed files with 1088 additions and 409 deletions

View File

@ -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<void> => {
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,

View File

@ -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);
// 레이아웃 관리

View File

@ -205,7 +205,7 @@ export class ScreenManagementService {
// ========================================
/**
*
* ( )
*/
async getTables(companyCode: string): Promise<TableInfo[]> {
try {
@ -242,6 +242,54 @@ export class ScreenManagementService {
}
}
/**
* ( )
*/
async getTableInfo(
tableName: string,
companyCode: string
): Promise<TableInfo | null> {
try {
console.log(`=== 단일 테이블 조회 시작: ${tableName} ===`);
// 테이블 존재 여부 확인
const tableExists = await prisma.$queryRaw<Array<{ table_name: string }>>`
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} 정보를 조회할 수 없습니다.`);
}
}
/**
*
*/

View File

@ -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>(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 (
<Card className={className}>
<CardHeader className="pb-3">
<CardTitle className="flex items-center space-x-2 text-sm">
<Grid className="h-4 w-4" />
<span> </span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* 격자 열 개수 */}
<div className="space-y-2">
<Label htmlFor="columns" className="text-xs font-medium">
</Label>
<div className="flex items-center space-x-2">
<Slider
id="columns"
min={1}
max={24}
step={1}
value={[localSettings.columns]}
onValueChange={(value) => handleSettingChange("columns", value[0])}
className="flex-1"
/>
<Input
type="number"
min={1}
max={24}
value={localSettings.columns}
onChange={(e) => handleSettingChange("columns", parseInt(e.target.value) || 12)}
className="w-16 text-xs"
/>
</div>
<div className="text-xs text-gray-500">: {localSettings.columns}</div>
</div>
{/* 격자 간격 */}
<div className="space-y-2">
<Label htmlFor="gap" className="text-xs font-medium">
(px)
</Label>
<div className="flex items-center space-x-2">
<Slider
id="gap"
min={0}
max={32}
step={2}
value={[localSettings.gap]}
onValueChange={(value) => handleSettingChange("gap", value[0])}
className="flex-1"
/>
<Input
type="number"
min={0}
max={32}
value={localSettings.gap}
onChange={(e) => handleSettingChange("gap", parseInt(e.target.value) || 16)}
className="w-16 text-xs"
/>
</div>
</div>
{/* 여백 */}
<div className="space-y-2">
<Label htmlFor="padding" className="text-xs font-medium">
(px)
</Label>
<div className="flex items-center space-x-2">
<Slider
id="padding"
min={0}
max={48}
step={4}
value={[localSettings.padding]}
onValueChange={(value) => handleSettingChange("padding", value[0])}
className="flex-1"
/>
<Input
type="number"
min={0}
max={48}
value={localSettings.padding}
onChange={(e) => handleSettingChange("padding", parseInt(e.target.value) || 16)}
className="w-16 text-xs"
/>
</div>
</div>
{/* 격자 스냅 */}
<div className="flex items-center justify-between">
<Label htmlFor="snapToGrid" className="text-xs font-medium">
</Label>
<Button
variant={localSettings.snapToGrid ? "default" : "outline"}
size="sm"
onClick={() => handleSettingChange("snapToGrid", !localSettings.snapToGrid)}
className="h-6 px-2 text-xs"
>
{localSettings.snapToGrid ? "ON" : "OFF"}
</Button>
</div>
{/* 격자 변경 안내 */}
<div className="rounded-md bg-blue-50 p-2 text-xs text-blue-700">
<div className="font-medium">💡 </div>
<div className="mt-1 text-blue-600">
.
</div>
</div>
{/* 격자 미리보기 */}
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<div className="rounded border bg-gray-50 p-2">
<div
className="grid h-16 gap-px"
style={{
gridTemplateColumns: `repeat(${localSettings.columns}, 1fr)`,
gap: `${Math.max(1, localSettings.gap / 4)}px`,
}}
>
{Array.from({ length: localSettings.columns }).map((_, i) => (
<div key={i} className="rounded-sm bg-blue-200" />
))}
</div>
</div>
</div>
{/* 초기화 버튼 */}
<Button variant="outline" size="sm" onClick={resetToDefault} className="w-full text-xs">
<RotateCcw className="mr-1 h-3 w-3" />
</Button>
</CardContent>
</Card>
);
}

View File

@ -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<LayoutData>({
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<LayoutData[]>([
{
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<HTMLDivElement>(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<TableInfo[]>([]);
const [expandedTables, setExpandedTables] = useState<Set<string>>(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,195 +465,61 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
};
// 임시 테이블 데이터 (API 실패 시 사용)
const getMockTables = (): TableInfo[] => [
// 사용하지 않는 getMockTables 함수 제거됨
// 특정 테이블에 대한 임시 데이터 생성
const createMockTableForScreen = (tableName: string): TableInfo => {
// 기본 컬럼들 생성
const baseColumns = [
{
tableName: "user_info",
tableLabel: "사용자 정보",
columns: [
tableName,
columnName: "id",
columnLabel: "ID",
webType: "number" as WebType,
dataType: "BIGINT",
isNullable: "NO",
},
{
tableName: "user_info",
columnName: "user_id",
columnLabel: "사용자 ID",
webType: "text",
tableName,
columnName: "name",
columnLabel: "이름",
webType: "text" as WebType,
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",
tableName,
columnName: "description",
columnLabel: "설명",
webType: "textarea",
webType: "textarea" as WebType,
dataType: "TEXT",
isNullable: "YES",
},
{
tableName: "product_info",
tableName,
columnName: "created_date",
columnLabel: "생성일",
webType: "date",
webType: "date" as WebType,
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",
tableName,
columnName: "updated_date",
columnLabel: "수정일",
webType: "date" as WebType,
dataType: "TIMESTAMP",
isNullable: "YES",
},
],
},
];
return {
tableName,
tableLabel: `${tableName} (임시)`,
columns: baseColumns,
};
};
// 테이블 확장/축소 토글
const toggleTableExpansion = useCallback((tableName: string) => {
setExpandedTables((prev) => {
@ -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<HTMLDivElement | null>(null);
// 스크롤 컨테이너 참조 (좌표 계산 정확도 향상)
const scrollContainerRef = useRef<HTMLDivElement | null>(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<Position>,
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<Position>,
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<Position>,
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
{/* 좌측 사이드바 - 테이블 타입 */}
<div className="flex w-80 flex-col border-r bg-gray-50">
<div className="border-b bg-white p-4">
<h3 className="mb-4 text-lg font-medium"> </h3>
<div className="mb-4">
<h3 className="text-lg font-medium"> </h3>
{selectedScreen && (
<div className="mt-2 rounded-md bg-blue-50 p-3">
<div className="text-sm font-medium text-blue-900"> </div>
<div className="text-xs text-blue-700">{selectedScreen.screenName}</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">{selectedScreen.tableName}</span>
</div>
</div>
)}
</div>
{/* 검색 입력창 */}
<div className="mb-4">
@ -1496,7 +1572,22 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
{/* 테이블 목록 */}
<div className="flex-1 overflow-y-auto">
{paginatedTables.map((table) => (
{paginatedTables.length === 0 ? (
<div className="flex h-full items-center justify-center p-8">
<div className="text-center">
<Database className="mx-auto mb-4 h-12 w-12 text-gray-300" />
<h3 className="mb-2 text-sm font-medium text-gray-900">
{selectedScreen ? "테이블 정보를 불러오는 중..." : "화면을 선택해주세요"}
</h3>
<p className="text-xs text-gray-500">
{selectedScreen
? `${selectedScreen.tableName} 테이블의 컬럼 정보를 조회하고 있습니다.`
: "화면을 선택하면 해당 테이블의 컬럼 정보가 표시됩니다."}
</p>
</div>
</div>
) : (
paginatedTables.map((table) => (
<div key={table.tableName} className="border-b bg-white">
{/* 테이블 헤더 */}
<div
@ -1588,7 +1679,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
</div>
)}
</div>
))}
))
)}
</div>
{/* 페이징 컨트롤 */}
@ -1634,23 +1726,50 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
onMouseUp={handleMarqueeEnd}
onContextMenu={handleCanvasContextMenu}
>
{layout.components.length === 0 ? (
<div className="flex h-full items-center justify-center">
<div className="text-center text-gray-500">
<Grid3X3 className="mx-auto mb-4 h-16 w-16 text-gray-300" />
<p className="mb-2 text-lg font-medium"> </p>
<p className="text-sm"> </p>
</div>
</div>
) : (
<div className="relative min-h-[600px]">
{/* 그리드 가이드 */}
{/* 항상 격자와 캔버스 표시 */}
<div className="relative min-h-[600px]" ref={canvasRef}>
{/* 동적 그리드 가이드 */}
<div className="pointer-events-none absolute inset-0">
<div className="grid h-full grid-cols-12 gap-1">
{Array.from({ length: 12 }).map((_, i) => (
<div key={i} className="border-r border-gray-200 last:border-r-0" />
<div
className="grid h-full gap-1"
style={{
gridTemplateColumns: `repeat(${layout.gridSettings?.columns || 12}, 1fr)`,
gap: `${layout.gridSettings?.gap || 16}px`,
padding: `${layout.gridSettings?.padding || 16}px`,
}}
>
{Array.from({ length: layout.gridSettings?.columns || 12 }).map((_, i) => (
<div key={i} className="border-r border-gray-200 opacity-30 last:border-r-0" />
))}
</div>
{/* 격자 스냅이 활성화된 경우 추가 가이드라인 */}
{layout.gridSettings?.snapToGrid && gridInfo && (
<div className="absolute inset-0">
{generateGridLines(
canvasRef.current?.clientWidth || 800,
canvasRef.current?.clientHeight || 600,
layout.gridSettings as GridUtilSettings,
).verticalLines.map((x, i) => (
<div
key={`v-${i}`}
className="absolute top-0 bottom-0 w-px bg-blue-200 opacity-50"
style={{ left: `${x}px` }}
/>
))}
{generateGridLines(
canvasRef.current?.clientWidth || 800,
canvasRef.current?.clientHeight || 600,
layout.gridSettings as GridUtilSettings,
).horizontalLines.map((y, i) => (
<div
key={`h-${i}`}
className="absolute right-0 left-0 h-px bg-blue-200 opacity-50"
style={{ top: `${y}px` }}
/>
))}
</div>
)}
</div>
{/* 마키 선택 사각형 */}
@ -1681,8 +1800,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
key={component.id}
component={component}
isSelected={
selectedComponent?.id === component.id ||
groupState.selectedComponents.includes(component.id)
selectedComponent?.id === component.id || groupState.selectedComponents.includes(component.id)
}
onClick={(e) => handleComponentClick(component, e)}
onDragStart={(e) => startComponentDrag(component, e)}
@ -1707,15 +1825,20 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
);
})}
</div>
)}
</div>
</div>
</div>
{/* 우측: 컴포넌트 스타일 편집 */}
<div className="w-80 border-l bg-gray-50">
<div className="h-full p-4">
<h3 className="mb-4 text-lg font-medium"> </h3>
<div className="h-full space-y-4 overflow-y-auto p-4">
{/* 격자 설정 */}
<GridControls
gridSettings={layout.gridSettings as GridUtilSettings}
onGridSettingsChange={handleGridSettingsChange}
/>
<h3 className="text-lg font-medium"> </h3>
{selectedComponent ? (
<div className="space-y-4">
@ -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<Position>,
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<Position>,
gridInfo,
layout.gridSettings as GridUtilSettings,
);
newY = snappedPos.y;
}
updateComponentProperty(selectedComponent.id, "position.y", newY);
}
}}
/>
@ -1764,7 +1919,40 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
{/* 크기 속성 */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="width"> ()</Label>
<Label htmlFor="width">
{layout.gridSettings?.snapToGrid ? "너비 (격자 컬럼)" : "너비 (픽셀)"}
</Label>
{layout.gridSettings?.snapToGrid && gridInfo ? (
// 격자 스냅이 활성화된 경우 컬럼 단위로 조정
<div className="space-y-1">
<Input
id="width"
type="number"
min="1"
max={layout.gridSettings.columns}
value={(() => {
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);
}}
/>
<div className="text-xs text-gray-500"> : {selectedComponent.size.width}px</div>
</div>
) : (
// 격자 스냅이 비활성화된 경우 픽셀 단위로 조정
<Input
id="width"
type="number"
@ -1773,14 +1961,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
onChange={(e) => {
const val = (e.target as HTMLInputElement).valueAsNumber;
if (Number.isFinite(val)) {
updateComponentProperty(
selectedComponent.id,
"size.width",
Math.max(20, Math.round(val)),
);
const newWidth = Math.max(20, Math.round(val));
updateComponentProperty(selectedComponent.id, "size.width", newWidth);
}
}}
/>
)}
</div>
<div className="space-y-2">
<Label htmlFor="height"> ()</Label>
@ -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);
}
}}
/>

View File

@ -0,0 +1,52 @@
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
interface SliderProps extends React.InputHTMLAttributes<HTMLInputElement> {
value?: number[];
onValueChange?: (value: number[]) => void;
min?: number;
max?: number;
step?: number;
className?: string;
}
const Slider = React.forwardRef<HTMLInputElement, SliderProps>(
({ className, value = [0], onValueChange, min = 0, max = 100, step = 1, ...props }, ref) => {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = parseFloat(e.target.value);
onValueChange?.([newValue]);
};
return (
<div className={cn("relative flex w-full touch-none items-center select-none", className)}>
<input
ref={ref}
type="range"
min={min}
max={max}
step={step}
value={value[0]}
onChange={handleChange}
className={cn(
"relative h-2 w-full cursor-pointer appearance-none rounded-lg bg-gray-200 outline-none",
"focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none",
"disabled:cursor-not-allowed disabled:opacity-50",
// WebKit 스타일
"[&::-webkit-slider-track]:h-2 [&::-webkit-slider-track]:rounded-lg [&::-webkit-slider-track]:bg-gray-200",
"[&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-0 [&::-webkit-slider-thumb]:bg-blue-600 [&::-webkit-slider-thumb]:shadow-sm",
// Firefox 스타일
"[&::-moz-range-track]:h-2 [&::-moz-range-track]:rounded-lg [&::-moz-range-track]:border-0 [&::-moz-range-track]:bg-gray-200",
"[&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:cursor-pointer [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-blue-600 [&::-moz-range-thumb]:shadow-sm",
)}
{...props}
/>
</div>
);
},
);
Slider.displayName = "Slider";
export { Slider };

View File

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

View File

@ -198,6 +198,7 @@ export interface GridSettings {
columns: number; // 기본값: 12
gap: number; // 기본값: 16px
padding: number; // 기본값: 16px
snapToGrid?: boolean; // 격자에 맞춤 여부 (기본값: true)
}
// 유효성 검증 규칙