Merge branch 'dev' of http://39.117.244.52:3000/kjs/ERP-node into commonCodeMng
This commit is contained in:
commit
523ebd020a
|
|
@ -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} 정보를 조회할 수 없습니다.`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블 컬럼 정보 조회
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,58 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { ArrowLeft, HelpCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
showBackButton?: boolean;
|
||||
onBack?: () => void;
|
||||
showHelpButton?: boolean;
|
||||
onHelp?: () => void;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PageHeader({
|
||||
title,
|
||||
subtitle,
|
||||
showBackButton = false,
|
||||
onBack,
|
||||
showHelpButton = false,
|
||||
onHelp,
|
||||
children,
|
||||
className,
|
||||
}: PageHeaderProps) {
|
||||
return (
|
||||
<div className={cn("flex items-center justify-between border-b border-slate-200 pb-6", className)}>
|
||||
<div className="flex items-center space-x-4">
|
||||
{showBackButton && (
|
||||
<Button variant="ghost" size="sm" onClick={onBack} className="h-8 w-8 p-0">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">{title}</h1>
|
||||
{subtitle && <p className="mt-1 text-sm text-slate-600">{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
{children}
|
||||
{showHelpButton && (
|
||||
<Button variant="outline" size="sm" onClick={onHelp} className="h-8 w-8 p-0">
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 페이지 헤더 액션 버튼 컴포넌트
|
||||
export function PageHeaderActions({ children, className }: { children: React.ReactNode; className?: string }) {
|
||||
return <div className={cn("flex items-center space-x-2", className)}>{children}</div>;
|
||||
}
|
||||
|
|
@ -24,8 +24,6 @@ import { menuScreenApi } from "@/lib/api/screen";
|
|||
import { toast } from "sonner";
|
||||
import { MainHeader } from "./MainHeader";
|
||||
import { ProfileModal } from "./ProfileModal";
|
||||
import { PageHeader } from "./PageHeader";
|
||||
import { getPageInfo } from "@/constants/pageInfo";
|
||||
|
||||
// useAuth의 UserInfo 타입을 확장
|
||||
interface ExtendedUserInfo {
|
||||
|
|
@ -408,10 +406,7 @@ export function AppLayout({ children }: AppLayoutProps) {
|
|||
</aside>
|
||||
|
||||
{/* 가운데 컨텐츠 영역 */}
|
||||
<main className="bg-background flex-1 p-6">
|
||||
<PageHeader title={getPageInfo(pathname).title} description={getPageInfo(pathname).description} />
|
||||
{children}
|
||||
</main>
|
||||
<main className="bg-background flex-1 p-6">{children}</main>
|
||||
</div>
|
||||
|
||||
{/* 프로필 수정 모달 */}
|
||||
|
|
|
|||
|
|
@ -1,21 +0,0 @@
|
|||
interface PageHeaderProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지 상단 헤더 컴포넌트
|
||||
* 제목, 설명, 추가 버튼 등을 표시
|
||||
*/
|
||||
export function PageHeader({ title, description }: PageHeaderProps) {
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">{title}</h1>
|
||||
{description && <p className="text-muted-foreground">{description}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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 };
|
||||
|
|
@ -26,7 +26,7 @@ export const FORM_VALIDATION = {
|
|||
} as const;
|
||||
|
||||
export const UI_CONFIG = {
|
||||
COMPANY_NAME: "PLM 솔루션",
|
||||
COMPANY_NAME: "WACE 솔루션",
|
||||
COPYRIGHT: "© 2025 WACE PLM Solution. All rights reserved.",
|
||||
POWERED_BY: "Powered by Spring Boot + Next.js",
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -1,64 +0,0 @@
|
|||
/**
|
||||
* 페이지별 제목과 설명 정보
|
||||
*/
|
||||
export interface PageInfo {
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export const PAGE_INFO: Record<string, PageInfo> = {
|
||||
// 메인 대시보드
|
||||
"/main": {
|
||||
title: "대시보드",
|
||||
description: "PLM 시스템의 주요 현황을 확인하세요",
|
||||
},
|
||||
|
||||
// 관리자 페이지들
|
||||
"/admin": {
|
||||
title: "관리자 대시보드",
|
||||
description: "시스템 관리 및 모니터링",
|
||||
},
|
||||
"/admin/company": {
|
||||
title: "회사 관리",
|
||||
description: "회사 정보를 등록하고 관리합니다",
|
||||
},
|
||||
"/admin/userMng": {
|
||||
title: "사용자 관리",
|
||||
description: "시스템 사용자를 관리합니다",
|
||||
},
|
||||
"/admin/menu": {
|
||||
title: "메뉴 관리",
|
||||
description: "시스템 메뉴를 관리합니다",
|
||||
},
|
||||
"/admin/i18n": {
|
||||
title: "다국어 관리",
|
||||
description: "다국어 번역을 관리합니다",
|
||||
},
|
||||
"/admin/tableMng": {
|
||||
title: "테이블 타입 관리",
|
||||
description: "데이터베이스 테이블과 컬럼 타입을 관리합니다",
|
||||
},
|
||||
|
||||
// 기타 페이지들
|
||||
"/multilang": {
|
||||
title: "다국어 설정",
|
||||
description: "언어 설정을 변경합니다",
|
||||
},
|
||||
"/dashboard": {
|
||||
title: "대시보드",
|
||||
description: "PLM 시스템 현황",
|
||||
},
|
||||
|
||||
// 기본값 (매핑되지 않은 페이지)
|
||||
default: {
|
||||
title: "WACE 솔루션",
|
||||
description: "제품 수명 주기 관리 시스템",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 현재 경로에 맞는 페이지 정보를 반환
|
||||
*/
|
||||
export function getPageInfo(pathname: string): PageInfo {
|
||||
return PAGE_INFO[pathname] || PAGE_INFO["default"];
|
||||
}
|
||||
|
|
@ -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