격자에 맞게 컴포넌트 배치
This commit is contained in:
parent
7002384393
commit
c3213b8a85
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
// 레이아웃 관리
|
||||
|
|
|
|||
|
|
@ -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} 정보를 조회할 수 없습니다.`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블 컬럼 정보 조회
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -198,6 +198,7 @@ export interface GridSettings {
|
|||
columns: number; // 기본값: 12
|
||||
gap: number; // 기본값: 16px
|
||||
padding: number; // 기본값: 16px
|
||||
snapToGrid?: boolean; // 격자에 맞춤 여부 (기본값: true)
|
||||
}
|
||||
|
||||
// 유효성 검증 규칙
|
||||
|
|
|
|||
Loading…
Reference in New Issue