격자에 맞게 컴포넌트 배치

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

File diff suppressed because it is too large Load Diff

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)
}
// 유효성 검증 규칙