2025-09-01 11:48:12 +09:00
|
|
|
"use client";
|
|
|
|
|
|
2025-09-01 14:26:39 +09:00
|
|
|
import { useState, useCallback, useEffect, useMemo } from "react";
|
2025-09-01 11:48:12 +09:00
|
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
|
|
|
import { Separator } from "@/components/ui/separator";
|
|
|
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
|
|
|
import {
|
|
|
|
|
Palette,
|
|
|
|
|
Grid3X3,
|
|
|
|
|
Type,
|
|
|
|
|
Calendar,
|
|
|
|
|
Hash,
|
|
|
|
|
CheckSquare,
|
|
|
|
|
Radio,
|
|
|
|
|
FileText,
|
|
|
|
|
Save,
|
|
|
|
|
Undo,
|
|
|
|
|
Redo,
|
|
|
|
|
Eye,
|
|
|
|
|
Group,
|
|
|
|
|
Ungroup,
|
|
|
|
|
Database,
|
|
|
|
|
Trash2,
|
2025-09-01 14:00:31 +09:00
|
|
|
Table,
|
|
|
|
|
Settings,
|
|
|
|
|
ChevronDown,
|
|
|
|
|
ChevronRight,
|
2025-09-01 14:26:39 +09:00
|
|
|
List,
|
|
|
|
|
AlignLeft,
|
2025-09-01 11:48:12 +09:00
|
|
|
} from "lucide-react";
|
|
|
|
|
import {
|
|
|
|
|
ScreenDefinition,
|
|
|
|
|
ComponentData,
|
|
|
|
|
LayoutData,
|
|
|
|
|
DragState,
|
|
|
|
|
GroupState,
|
|
|
|
|
ComponentType,
|
|
|
|
|
WebType,
|
|
|
|
|
WidgetComponent,
|
|
|
|
|
ColumnInfo,
|
2025-09-01 14:00:31 +09:00
|
|
|
TableInfo,
|
2025-09-01 11:48:12 +09:00
|
|
|
} from "@/types/screen";
|
|
|
|
|
import { generateComponentId } from "@/lib/utils/generateId";
|
|
|
|
|
import ContainerComponent from "./layout/ContainerComponent";
|
|
|
|
|
import RowComponent from "./layout/RowComponent";
|
|
|
|
|
import ColumnComponent from "./layout/ColumnComponent";
|
|
|
|
|
import WidgetFactory from "./WidgetFactory";
|
|
|
|
|
import TableTypeSelector from "./TableTypeSelector";
|
|
|
|
|
import ScreenPreview from "./ScreenPreview";
|
|
|
|
|
import TemplateManager from "./TemplateManager";
|
|
|
|
|
import StyleEditor from "./StyleEditor";
|
|
|
|
|
import { Label } from "@/components/ui/label";
|
|
|
|
|
import { Input } from "@/components/ui/input";
|
2025-09-01 14:00:31 +09:00
|
|
|
import { Badge } from "@/components/ui/badge";
|
2025-09-01 11:48:12 +09:00
|
|
|
|
|
|
|
|
interface ScreenDesignerProps {
|
2025-09-01 14:00:31 +09:00
|
|
|
selectedScreen: ScreenDefinition | null;
|
|
|
|
|
onBackToList: () => void;
|
2025-09-01 11:48:12 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface ComponentMoveState {
|
|
|
|
|
isMoving: boolean;
|
|
|
|
|
movingComponent: ComponentData | null;
|
|
|
|
|
originalPosition: { x: number; y: number };
|
|
|
|
|
currentPosition: { x: number; y: number };
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-01 14:00:31 +09:00
|
|
|
export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenDesignerProps) {
|
2025-09-01 11:48:12 +09:00
|
|
|
const [layout, setLayout] = useState<LayoutData>({
|
|
|
|
|
components: [],
|
|
|
|
|
gridSettings: { columns: 12, gap: 16, padding: 16 },
|
|
|
|
|
});
|
|
|
|
|
const [selectedComponent, setSelectedComponent] = useState<ComponentData | null>(null);
|
2025-09-01 14:26:39 +09:00
|
|
|
const [dragState, setDragState] = useState({
|
2025-09-01 11:48:12 +09:00
|
|
|
isDragging: false,
|
2025-09-01 14:26:39 +09:00
|
|
|
draggedComponent: null as ComponentData | null,
|
|
|
|
|
originalPosition: { x: 0, y: 0 },
|
|
|
|
|
currentPosition: { x: 0, y: 0 },
|
2025-09-01 11:48:12 +09:00
|
|
|
});
|
|
|
|
|
const [groupState, setGroupState] = useState<GroupState>({
|
|
|
|
|
isGrouping: false,
|
|
|
|
|
selectedComponents: [],
|
|
|
|
|
groupTarget: null,
|
|
|
|
|
groupMode: "create",
|
|
|
|
|
});
|
|
|
|
|
const [moveState, setMoveState] = useState<ComponentMoveState>({
|
|
|
|
|
isMoving: false,
|
|
|
|
|
movingComponent: null,
|
|
|
|
|
originalPosition: { x: 0, y: 0 },
|
|
|
|
|
currentPosition: { x: 0, y: 0 },
|
|
|
|
|
});
|
2025-09-01 14:00:31 +09:00
|
|
|
const [activeTab, setActiveTab] = useState("tables");
|
|
|
|
|
const [tables, setTables] = useState<TableInfo[]>([]);
|
|
|
|
|
const [expandedTables, setExpandedTables] = useState<Set<string>>(new Set());
|
2025-09-01 11:48:12 +09:00
|
|
|
|
2025-09-01 14:26:39 +09:00
|
|
|
// 테이블 검색 및 페이징 상태 추가
|
|
|
|
|
const [searchTerm, setSearchTerm] = useState("");
|
|
|
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
|
|
|
const [itemsPerPage] = useState(10);
|
|
|
|
|
|
2025-09-01 14:00:31 +09:00
|
|
|
// 테이블 데이터 로드 (실제로는 API에서 가져와야 함)
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const fetchTables = async () => {
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch("http://localhost:8080/api/screen-management/tables", {
|
|
|
|
|
headers: {
|
|
|
|
|
Authorization: `Bearer ${localStorage.getItem("authToken")}`,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
if (data.success) {
|
|
|
|
|
setTables(data.data);
|
|
|
|
|
} else {
|
|
|
|
|
console.error("테이블 조회 실패:", data.message);
|
|
|
|
|
// 임시 데이터로 폴백
|
|
|
|
|
setTables(getMockTables());
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
console.error("테이블 조회 실패:", response.status);
|
|
|
|
|
// 임시 데이터로 폴백
|
|
|
|
|
setTables(getMockTables());
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("테이블 조회 중 오류:", error);
|
|
|
|
|
// 임시 데이터로 폴백
|
|
|
|
|
setTables(getMockTables());
|
|
|
|
|
}
|
|
|
|
|
};
|
2025-09-01 11:48:12 +09:00
|
|
|
|
2025-09-01 14:00:31 +09:00
|
|
|
fetchTables();
|
|
|
|
|
}, []);
|
2025-09-01 11:48:12 +09:00
|
|
|
|
2025-09-01 14:26:39 +09:00
|
|
|
// 검색된 테이블 필터링
|
|
|
|
|
const filteredTables = useMemo(() => {
|
|
|
|
|
if (!searchTerm.trim()) return tables;
|
|
|
|
|
|
|
|
|
|
return tables.filter(
|
|
|
|
|
(table) =>
|
|
|
|
|
table.tableName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
|
|
|
table.tableLabel.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
|
|
|
table.columns.some(
|
|
|
|
|
(column) =>
|
|
|
|
|
column.columnName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
|
|
|
(column.columnLabel || column.columnName).toLowerCase().includes(searchTerm.toLowerCase()),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}, [tables, searchTerm]);
|
|
|
|
|
|
|
|
|
|
// 페이징된 테이블
|
|
|
|
|
const paginatedTables = useMemo(() => {
|
|
|
|
|
const startIndex = (currentPage - 1) * itemsPerPage;
|
|
|
|
|
const endIndex = startIndex + itemsPerPage;
|
|
|
|
|
return filteredTables.slice(startIndex, endIndex);
|
|
|
|
|
}, [filteredTables, currentPage, itemsPerPage]);
|
|
|
|
|
|
|
|
|
|
// 총 페이지 수 계산
|
|
|
|
|
const totalPages = Math.ceil(filteredTables.length / itemsPerPage);
|
|
|
|
|
|
|
|
|
|
// 페이지 변경 핸들러
|
|
|
|
|
const handlePageChange = (page: number) => {
|
|
|
|
|
setCurrentPage(page);
|
|
|
|
|
setExpandedTables(new Set()); // 페이지 변경 시 확장 상태 초기화
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 검색어 변경 핸들러
|
|
|
|
|
const handleSearchChange = (value: string) => {
|
|
|
|
|
setSearchTerm(value);
|
|
|
|
|
setCurrentPage(1); // 검색 시 첫 페이지로 이동
|
|
|
|
|
setExpandedTables(new Set()); // 검색 시 확장 상태 초기화
|
|
|
|
|
};
|
|
|
|
|
|
2025-09-01 14:00:31 +09:00
|
|
|
// 임시 테이블 데이터 (API 실패 시 사용)
|
|
|
|
|
const getMockTables = (): TableInfo[] => [
|
|
|
|
|
{
|
|
|
|
|
tableName: "user_info",
|
|
|
|
|
tableLabel: "사용자 정보",
|
|
|
|
|
columns: [
|
|
|
|
|
{
|
|
|
|
|
tableName: "user_info",
|
|
|
|
|
columnName: "user_id",
|
|
|
|
|
columnLabel: "사용자 ID",
|
|
|
|
|
webType: "text",
|
|
|
|
|
dataType: "VARCHAR",
|
|
|
|
|
isNullable: "NO",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
tableName: "user_info",
|
|
|
|
|
columnName: "user_name",
|
|
|
|
|
columnLabel: "사용자명",
|
|
|
|
|
webType: "text",
|
|
|
|
|
dataType: "VARCHAR",
|
|
|
|
|
isNullable: "NO",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
tableName: "user_info",
|
|
|
|
|
columnName: "email",
|
|
|
|
|
columnLabel: "이메일",
|
|
|
|
|
webType: "email",
|
|
|
|
|
dataType: "VARCHAR",
|
|
|
|
|
isNullable: "YES",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
tableName: "user_info",
|
|
|
|
|
columnName: "phone",
|
|
|
|
|
columnLabel: "전화번호",
|
|
|
|
|
webType: "tel",
|
|
|
|
|
dataType: "VARCHAR",
|
|
|
|
|
isNullable: "YES",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
tableName: "user_info",
|
|
|
|
|
columnName: "birth_date",
|
|
|
|
|
columnLabel: "생년월일",
|
|
|
|
|
webType: "date",
|
|
|
|
|
dataType: "DATE",
|
|
|
|
|
isNullable: "YES",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
tableName: "user_info",
|
|
|
|
|
columnName: "is_active",
|
|
|
|
|
columnLabel: "활성화",
|
|
|
|
|
webType: "checkbox",
|
|
|
|
|
dataType: "BOOLEAN",
|
|
|
|
|
isNullable: "NO",
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
tableName: "product_info",
|
|
|
|
|
tableLabel: "제품 정보",
|
|
|
|
|
columns: [
|
|
|
|
|
{
|
|
|
|
|
tableName: "product_info",
|
|
|
|
|
columnName: "product_id",
|
|
|
|
|
columnLabel: "제품 ID",
|
|
|
|
|
webType: "text",
|
|
|
|
|
dataType: "VARCHAR",
|
|
|
|
|
isNullable: "NO",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
tableName: "product_info",
|
|
|
|
|
columnName: "product_name",
|
|
|
|
|
columnLabel: "제품명",
|
|
|
|
|
webType: "text",
|
|
|
|
|
dataType: "VARCHAR",
|
|
|
|
|
isNullable: "NO",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
tableName: "product_info",
|
|
|
|
|
columnName: "category",
|
|
|
|
|
columnLabel: "카테고리",
|
|
|
|
|
webType: "select",
|
|
|
|
|
dataType: "VARCHAR",
|
|
|
|
|
isNullable: "YES",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
tableName: "product_info",
|
|
|
|
|
columnName: "price",
|
|
|
|
|
columnLabel: "가격",
|
|
|
|
|
webType: "number",
|
|
|
|
|
dataType: "DECIMAL",
|
|
|
|
|
isNullable: "YES",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
tableName: "product_info",
|
|
|
|
|
columnName: "description",
|
|
|
|
|
columnLabel: "설명",
|
|
|
|
|
webType: "textarea",
|
|
|
|
|
dataType: "TEXT",
|
|
|
|
|
isNullable: "YES",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
tableName: "product_info",
|
|
|
|
|
columnName: "created_date",
|
|
|
|
|
columnLabel: "생성일",
|
|
|
|
|
webType: "date",
|
|
|
|
|
dataType: "TIMESTAMP",
|
|
|
|
|
isNullable: "NO",
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
tableName: "order_info",
|
|
|
|
|
tableLabel: "주문 정보",
|
|
|
|
|
columns: [
|
|
|
|
|
{
|
|
|
|
|
tableName: "order_info",
|
|
|
|
|
columnName: "order_id",
|
|
|
|
|
columnLabel: "주문 ID",
|
|
|
|
|
webType: "text",
|
|
|
|
|
dataType: "VARCHAR",
|
|
|
|
|
isNullable: "NO",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
tableName: "order_info",
|
|
|
|
|
columnName: "customer_name",
|
|
|
|
|
columnLabel: "고객명",
|
|
|
|
|
webType: "text",
|
|
|
|
|
dataType: "VARCHAR",
|
|
|
|
|
isNullable: "NO",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
tableName: "order_info",
|
|
|
|
|
columnName: "order_date",
|
|
|
|
|
columnLabel: "주문일",
|
|
|
|
|
webType: "date",
|
|
|
|
|
dataType: "DATE",
|
|
|
|
|
isNullable: "NO",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
tableName: "order_info",
|
|
|
|
|
columnName: "total_amount",
|
|
|
|
|
columnLabel: "총 금액",
|
|
|
|
|
webType: "number",
|
|
|
|
|
dataType: "DECIMAL",
|
|
|
|
|
isNullable: "NO",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
tableName: "order_info",
|
|
|
|
|
columnName: "status",
|
|
|
|
|
columnLabel: "상태",
|
|
|
|
|
webType: "select",
|
|
|
|
|
dataType: "VARCHAR",
|
|
|
|
|
isNullable: "NO",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
tableName: "order_info",
|
|
|
|
|
columnName: "notes",
|
|
|
|
|
columnLabel: "비고",
|
|
|
|
|
webType: "textarea",
|
|
|
|
|
dataType: "TEXT",
|
|
|
|
|
isNullable: "YES",
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
];
|
2025-09-01 11:48:12 +09:00
|
|
|
|
2025-09-01 14:00:31 +09:00
|
|
|
// 테이블 확장/축소 토글
|
|
|
|
|
const toggleTableExpansion = useCallback((tableName: string) => {
|
|
|
|
|
setExpandedTables((prev) => {
|
|
|
|
|
const newSet = new Set(prev);
|
|
|
|
|
if (newSet.has(tableName)) {
|
|
|
|
|
newSet.delete(tableName);
|
|
|
|
|
} else {
|
|
|
|
|
newSet.add(tableName);
|
|
|
|
|
}
|
|
|
|
|
return newSet;
|
|
|
|
|
});
|
2025-09-01 11:48:12 +09:00
|
|
|
}, []);
|
|
|
|
|
|
2025-09-01 14:00:31 +09:00
|
|
|
// 웹타입에 따른 위젯 타입 매핑
|
|
|
|
|
const getWidgetTypeFromWebType = useCallback((webType: string): string => {
|
|
|
|
|
switch (webType) {
|
|
|
|
|
case "text":
|
|
|
|
|
case "email":
|
|
|
|
|
case "tel":
|
|
|
|
|
return "text";
|
|
|
|
|
case "number":
|
|
|
|
|
case "decimal":
|
|
|
|
|
return "number";
|
|
|
|
|
case "date":
|
|
|
|
|
case "datetime":
|
|
|
|
|
return "date";
|
|
|
|
|
case "select":
|
|
|
|
|
case "dropdown":
|
|
|
|
|
return "select";
|
|
|
|
|
case "textarea":
|
|
|
|
|
case "text_area":
|
|
|
|
|
return "textarea";
|
|
|
|
|
case "checkbox":
|
|
|
|
|
case "boolean":
|
|
|
|
|
return "checkbox";
|
|
|
|
|
case "radio":
|
|
|
|
|
return "radio";
|
|
|
|
|
default:
|
|
|
|
|
return "text";
|
|
|
|
|
}
|
2025-09-01 11:48:12 +09:00
|
|
|
}, []);
|
|
|
|
|
|
2025-09-01 14:00:31 +09:00
|
|
|
// 컴포넌트 추가 함수
|
|
|
|
|
const addComponent = useCallback((componentData: Partial<ComponentData>, position: { x: number; y: number }) => {
|
|
|
|
|
const newComponent: ComponentData = {
|
|
|
|
|
id: generateComponentId(),
|
|
|
|
|
type: "widget",
|
2025-09-01 11:48:12 +09:00
|
|
|
position,
|
2025-09-01 14:00:31 +09:00
|
|
|
size: { width: 6, height: 60 },
|
|
|
|
|
tableName: "",
|
|
|
|
|
columnName: "",
|
|
|
|
|
widgetType: "text",
|
|
|
|
|
label: "",
|
|
|
|
|
required: false,
|
|
|
|
|
readonly: false,
|
|
|
|
|
...componentData,
|
|
|
|
|
} as ComponentData;
|
2025-09-01 11:48:12 +09:00
|
|
|
|
|
|
|
|
setLayout((prev) => ({
|
|
|
|
|
...prev,
|
|
|
|
|
components: [...prev.components, newComponent],
|
|
|
|
|
}));
|
|
|
|
|
}, []);
|
|
|
|
|
|
2025-09-01 14:00:31 +09:00
|
|
|
// 컴포넌트 제거 함수
|
|
|
|
|
const removeComponent = useCallback(
|
|
|
|
|
(componentId: string) => {
|
|
|
|
|
setLayout((prev) => ({
|
|
|
|
|
...prev,
|
|
|
|
|
components: prev.components.filter((comp) => comp.id !== componentId),
|
|
|
|
|
}));
|
|
|
|
|
if (selectedComponent?.id === componentId) {
|
|
|
|
|
setSelectedComponent(null);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
[selectedComponent],
|
|
|
|
|
);
|
2025-09-01 11:48:12 +09:00
|
|
|
|
2025-09-01 14:00:31 +09:00
|
|
|
// 컴포넌트 속성 업데이트 함수
|
|
|
|
|
const updateComponentProperty = useCallback((componentId: string, propertyPath: string, value: any) => {
|
2025-09-01 11:48:12 +09:00
|
|
|
setLayout((prev) => ({
|
|
|
|
|
...prev,
|
2025-09-01 14:00:31 +09:00
|
|
|
components: prev.components.map((comp) => {
|
|
|
|
|
if (comp.id === componentId) {
|
|
|
|
|
const newComp = { ...comp };
|
|
|
|
|
const pathParts = propertyPath.split(".");
|
|
|
|
|
let current: any = newComp;
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < pathParts.length - 1; i++) {
|
|
|
|
|
current = current[pathParts[i]];
|
2025-09-01 11:48:12 +09:00
|
|
|
}
|
2025-09-01 14:00:31 +09:00
|
|
|
current[pathParts[pathParts.length - 1]] = value;
|
|
|
|
|
|
|
|
|
|
return newComp;
|
2025-09-01 11:48:12 +09:00
|
|
|
}
|
2025-09-01 14:00:31 +09:00
|
|
|
return comp;
|
2025-09-01 11:48:12 +09:00
|
|
|
}),
|
|
|
|
|
}));
|
|
|
|
|
}, []);
|
|
|
|
|
|
2025-09-01 14:00:31 +09:00
|
|
|
// 레이아웃 저장 함수
|
|
|
|
|
const saveLayout = useCallback(async () => {
|
|
|
|
|
try {
|
|
|
|
|
// TODO: 실제 API 호출로 변경
|
|
|
|
|
console.log("레이아웃 저장:", layout);
|
|
|
|
|
// await saveLayoutAPI(selectedScreen.screenId, layout);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("레이아웃 저장 실패:", error);
|
|
|
|
|
}
|
|
|
|
|
}, [layout, selectedScreen]);
|
2025-09-01 11:48:12 +09:00
|
|
|
|
2025-09-01 14:26:39 +09:00
|
|
|
// 드래그 시작 (새 컴포넌트 추가)
|
|
|
|
|
const startDrag = useCallback((component: Partial<ComponentData>, e: React.DragEvent) => {
|
|
|
|
|
setDragState({
|
2025-09-01 11:48:12 +09:00
|
|
|
isDragging: true,
|
2025-09-01 14:26:39 +09:00
|
|
|
draggedComponent: component as ComponentData,
|
|
|
|
|
originalPosition: { x: 0, y: 0 },
|
|
|
|
|
currentPosition: { x: 0, y: 0 },
|
|
|
|
|
});
|
|
|
|
|
e.dataTransfer.setData("application/json", JSON.stringify(component));
|
2025-09-01 11:48:12 +09:00
|
|
|
}, []);
|
|
|
|
|
|
2025-09-01 14:26:39 +09:00
|
|
|
// 기존 컴포넌트 드래그 시작 (재배치)
|
|
|
|
|
const startComponentDrag = useCallback((component: ComponentData, e: React.DragEvent) => {
|
|
|
|
|
setDragState({
|
|
|
|
|
isDragging: true,
|
|
|
|
|
draggedComponent: component,
|
|
|
|
|
originalPosition: component.position,
|
|
|
|
|
currentPosition: component.position,
|
|
|
|
|
});
|
|
|
|
|
e.dataTransfer.setData("application/json", JSON.stringify({ ...component, isMoving: true }));
|
2025-09-01 14:00:31 +09:00
|
|
|
}, []);
|
2025-09-01 11:48:12 +09:00
|
|
|
|
2025-09-01 14:26:39 +09:00
|
|
|
// 드래그 중
|
|
|
|
|
const onDragOver = useCallback(
|
2025-09-01 14:00:31 +09:00
|
|
|
(e: React.DragEvent) => {
|
|
|
|
|
e.preventDefault();
|
2025-09-01 14:26:39 +09:00
|
|
|
if (dragState.isDragging) {
|
|
|
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
|
|
|
const x = Math.floor((e.clientX - rect.left) / 80) * 80;
|
|
|
|
|
const y = Math.floor((e.clientY - rect.top) / 60) * 60;
|
|
|
|
|
|
|
|
|
|
setDragState((prev) => ({
|
|
|
|
|
...prev,
|
|
|
|
|
currentPosition: { x, y },
|
|
|
|
|
}));
|
|
|
|
|
}
|
2025-09-01 11:48:12 +09:00
|
|
|
},
|
2025-09-01 14:26:39 +09:00
|
|
|
[dragState.isDragging],
|
2025-09-01 11:48:12 +09:00
|
|
|
);
|
|
|
|
|
|
2025-09-01 14:26:39 +09:00
|
|
|
// 드롭 처리
|
|
|
|
|
const onDrop = useCallback((e: React.DragEvent) => {
|
2025-09-01 14:00:31 +09:00
|
|
|
e.preventDefault();
|
2025-09-01 14:26:39 +09:00
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const data = JSON.parse(e.dataTransfer.getData("application/json"));
|
|
|
|
|
|
|
|
|
|
if (data.isMoving) {
|
|
|
|
|
// 기존 컴포넌트 재배치
|
|
|
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
|
|
|
const x = Math.floor((e.clientX - rect.left) / 80) * 80;
|
|
|
|
|
const y = Math.floor((e.clientY - rect.top) / 60) * 60;
|
|
|
|
|
|
|
|
|
|
setLayout((prev) => ({
|
|
|
|
|
...prev,
|
|
|
|
|
components: prev.components.map((comp) => (comp.id === data.id ? { ...comp, position: { x, y } } : comp)),
|
|
|
|
|
}));
|
|
|
|
|
} else {
|
|
|
|
|
// 새 컴포넌트 추가
|
|
|
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
|
|
|
const x = Math.floor((e.clientX - rect.left) / 80) * 80;
|
|
|
|
|
const y = Math.floor((e.clientY - rect.top) / 60) * 60;
|
|
|
|
|
|
|
|
|
|
const newComponent: ComponentData = {
|
|
|
|
|
...data,
|
|
|
|
|
id: generateComponentId(),
|
|
|
|
|
position: { x, y },
|
|
|
|
|
} as ComponentData;
|
|
|
|
|
|
|
|
|
|
setLayout((prev) => ({
|
|
|
|
|
...prev,
|
|
|
|
|
components: [...prev.components, newComponent],
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("드롭 처리 중 오류:", error);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setDragState({
|
|
|
|
|
isDragging: false,
|
|
|
|
|
draggedComponent: null,
|
|
|
|
|
originalPosition: { x: 0, y: 0 },
|
|
|
|
|
currentPosition: { x: 0, y: 0 },
|
|
|
|
|
});
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// 드래그 종료
|
|
|
|
|
const endDrag = useCallback(() => {
|
|
|
|
|
setDragState({
|
|
|
|
|
isDragging: false,
|
|
|
|
|
draggedComponent: null,
|
|
|
|
|
originalPosition: { x: 0, y: 0 },
|
|
|
|
|
currentPosition: { x: 0, y: 0 },
|
|
|
|
|
});
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// 컴포넌트 클릭 (선택)
|
|
|
|
|
const handleComponentClick = useCallback((component: ComponentData) => {
|
|
|
|
|
setSelectedComponent(component);
|
2025-09-01 14:00:31 +09:00
|
|
|
}, []);
|
|
|
|
|
|
2025-09-01 14:26:39 +09:00
|
|
|
// 컴포넌트 삭제
|
|
|
|
|
const deleteComponent = useCallback(
|
|
|
|
|
(componentId: string) => {
|
|
|
|
|
setLayout((prev) => ({
|
|
|
|
|
...prev,
|
|
|
|
|
components: prev.components.filter((comp) => comp.id !== componentId),
|
|
|
|
|
}));
|
|
|
|
|
if (selectedComponent?.id === componentId) {
|
|
|
|
|
setSelectedComponent(null);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
[selectedComponent],
|
|
|
|
|
);
|
|
|
|
|
|
2025-09-01 14:00:31 +09:00
|
|
|
// 화면이 선택되지 않았을 때 처리
|
|
|
|
|
if (!selectedScreen) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex h-full items-center justify-center">
|
|
|
|
|
<div className="text-center text-gray-500">
|
|
|
|
|
<Palette className="mx-auto mb-4 h-16 w-16 text-gray-300" />
|
|
|
|
|
<p className="mb-4 text-lg">설계할 화면을 선택해주세요</p>
|
|
|
|
|
<p className="mb-6 text-sm">화면 목록에서 화면을 선택한 후 설계기를 사용하세요</p>
|
|
|
|
|
<Button onClick={onBackToList} variant="outline">
|
|
|
|
|
화면 목록으로 돌아가기
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-01 11:48:12 +09:00
|
|
|
return (
|
2025-09-01 14:00:31 +09:00
|
|
|
<div className="flex h-full w-full flex-col">
|
|
|
|
|
{/* 상단 헤더 */}
|
|
|
|
|
<div className="flex items-center justify-between border-b bg-white p-4 shadow-sm">
|
|
|
|
|
<div className="flex items-center space-x-4">
|
|
|
|
|
<h2 className="text-xl font-semibold text-gray-900">{selectedScreen.screenName} - 화면 설계</h2>
|
|
|
|
|
<Badge variant="outline" className="font-mono">
|
|
|
|
|
{selectedScreen.tableName}
|
|
|
|
|
</Badge>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center space-x-2">
|
|
|
|
|
<Button variant="outline" size="sm" onClick={() => {}}>
|
|
|
|
|
<Undo className="mr-2 h-4 w-4" />
|
|
|
|
|
실행 취소
|
|
|
|
|
</Button>
|
|
|
|
|
<Button variant="outline" size="sm" onClick={() => {}}>
|
|
|
|
|
<Redo className="mr-2 h-4 w-4" />
|
|
|
|
|
다시 실행
|
|
|
|
|
</Button>
|
|
|
|
|
<Button onClick={saveLayout} className="bg-blue-600 hover:bg-blue-700">
|
|
|
|
|
<Save className="mr-2 h-4 w-4" />
|
|
|
|
|
저장
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
2025-09-01 11:48:12 +09:00
|
|
|
</div>
|
|
|
|
|
|
2025-09-01 14:00:31 +09:00
|
|
|
{/* 메인 컨텐츠 영역 */}
|
|
|
|
|
<div className="flex flex-1 overflow-hidden">
|
2025-09-01 14:26:39 +09:00
|
|
|
{/* 좌측 사이드바 - 테이블 타입 */}
|
|
|
|
|
<div className="flex w-80 flex-col border-r bg-gray-50">
|
|
|
|
|
<div className="border-b bg-white p-4">
|
2025-09-01 14:00:31 +09:00
|
|
|
<h3 className="mb-4 text-lg font-medium">테이블 타입</h3>
|
2025-09-01 14:26:39 +09:00
|
|
|
|
|
|
|
|
{/* 검색 입력창 */}
|
|
|
|
|
<div className="mb-4">
|
|
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
placeholder="테이블명, 컬럼명으로 검색..."
|
|
|
|
|
value={searchTerm}
|
|
|
|
|
onChange={(e) => handleSearchChange(e.target.value)}
|
|
|
|
|
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-transparent focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 검색 결과 정보 */}
|
|
|
|
|
<div className="mb-2 text-sm text-gray-600">
|
|
|
|
|
총 {filteredTables.length}개 테이블 중 {(currentPage - 1) * itemsPerPage + 1}-
|
|
|
|
|
{Math.min(currentPage * itemsPerPage, filteredTables.length)}번째
|
|
|
|
|
</div>
|
|
|
|
|
|
2025-09-01 14:00:31 +09:00
|
|
|
<p className="mb-4 text-sm text-gray-600">테이블과 컬럼을 드래그하여 캔버스에 배치하세요.</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
2025-09-01 14:26:39 +09:00
|
|
|
{/* 테이블 목록 */}
|
|
|
|
|
<div className="flex-1 overflow-y-auto">
|
|
|
|
|
{paginatedTables.map((table) => (
|
|
|
|
|
<div key={table.tableName} className="border-b bg-white">
|
2025-09-01 14:00:31 +09:00
|
|
|
{/* 테이블 헤더 */}
|
2025-09-01 11:48:12 +09:00
|
|
|
<div
|
2025-09-01 14:26:39 +09:00
|
|
|
className="flex cursor-pointer items-center justify-between p-3 hover:bg-gray-100"
|
2025-09-01 14:00:31 +09:00
|
|
|
draggable
|
|
|
|
|
onDragStart={(e) =>
|
|
|
|
|
startDrag(
|
|
|
|
|
{
|
|
|
|
|
type: "container",
|
|
|
|
|
tableName: table.tableName,
|
|
|
|
|
label: table.tableLabel,
|
2025-09-01 14:26:39 +09:00
|
|
|
size: { width: 12, height: 120 },
|
2025-09-01 14:00:31 +09:00
|
|
|
},
|
|
|
|
|
e,
|
|
|
|
|
)
|
|
|
|
|
}
|
2025-09-01 11:48:12 +09:00
|
|
|
>
|
2025-09-01 14:00:31 +09:00
|
|
|
<div className="flex items-center space-x-2">
|
2025-09-01 14:26:39 +09:00
|
|
|
<Database className="h-4 w-4 text-blue-600" />
|
|
|
|
|
<div>
|
|
|
|
|
<div className="text-sm font-medium">{table.tableLabel}</div>
|
|
|
|
|
<div className="text-xs text-gray-500">{table.tableName}</div>
|
|
|
|
|
</div>
|
2025-09-01 14:00:31 +09:00
|
|
|
</div>
|
2025-09-01 14:26:39 +09:00
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => toggleTableExpansion(table.tableName)}
|
|
|
|
|
className="h-6 w-6 p-0"
|
|
|
|
|
>
|
|
|
|
|
{expandedTables.has(table.tableName) ? (
|
|
|
|
|
<ChevronDown className="h-4 w-4" />
|
|
|
|
|
) : (
|
|
|
|
|
<ChevronRight className="h-4 w-4" />
|
|
|
|
|
)}
|
|
|
|
|
</Button>
|
2025-09-01 14:00:31 +09:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 컬럼 목록 */}
|
|
|
|
|
{expandedTables.has(table.tableName) && (
|
|
|
|
|
<div className="bg-gray-25 border-t">
|
|
|
|
|
{table.columns.map((column) => (
|
|
|
|
|
<div
|
|
|
|
|
key={column.columnName}
|
2025-09-01 14:26:39 +09:00
|
|
|
className="flex cursor-pointer items-center space-x-2 p-2 pl-6 hover:bg-gray-100"
|
2025-09-01 14:00:31 +09:00
|
|
|
draggable
|
|
|
|
|
onDragStart={(e) =>
|
|
|
|
|
startDrag(
|
|
|
|
|
{
|
|
|
|
|
type: "widget",
|
|
|
|
|
tableName: table.tableName,
|
|
|
|
|
columnName: column.columnName,
|
|
|
|
|
widgetType: getWidgetTypeFromWebType(column.webType || "text"),
|
|
|
|
|
label: column.columnLabel || column.columnName,
|
|
|
|
|
size: { width: 6, height: 60 },
|
|
|
|
|
},
|
|
|
|
|
e,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
>
|
2025-09-01 14:26:39 +09:00
|
|
|
<div className="flex-shrink-0">
|
|
|
|
|
{column.webType === "text" && <Type className="h-3 w-3 text-blue-600" />}
|
|
|
|
|
{column.webType === "number" && <Hash className="h-3 w-3 text-green-600" />}
|
|
|
|
|
{column.webType === "date" && <Calendar className="h-3 w-3 text-purple-600" />}
|
|
|
|
|
{column.webType === "select" && <List className="h-3 w-3 text-orange-600" />}
|
|
|
|
|
{column.webType === "textarea" && <AlignLeft className="h-3 w-3 text-indigo-600" />}
|
|
|
|
|
{column.webType === "checkbox" && <CheckSquare className="h-3 w-3 text-blue-600" />}
|
|
|
|
|
{column.webType === "radio" && <Radio className="h-3 w-3 text-blue-600" />}
|
|
|
|
|
{!["text", "number", "date", "select", "textarea", "checkbox", "radio"].includes(
|
|
|
|
|
column.webType,
|
|
|
|
|
) && <Type className="h-3 w-3 text-blue-600" />}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex-1">
|
|
|
|
|
<div className="text-sm font-medium">{column.columnLabel || column.columnName}</div>
|
|
|
|
|
<div className="text-xs text-gray-500">{column.columnName}</div>
|
2025-09-01 14:00:31 +09:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
2025-09-01 14:26:39 +09:00
|
|
|
|
|
|
|
|
{/* 페이징 컨트롤 */}
|
|
|
|
|
{totalPages > 1 && (
|
|
|
|
|
<div className="border-t bg-white p-4">
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => handlePageChange(currentPage - 1)}
|
|
|
|
|
disabled={currentPage === 1}
|
|
|
|
|
>
|
|
|
|
|
이전
|
|
|
|
|
</Button>
|
|
|
|
|
|
|
|
|
|
<div className="text-sm text-gray-600">
|
|
|
|
|
{currentPage} / {totalPages}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => handlePageChange(currentPage + 1)}
|
|
|
|
|
disabled={currentPage === totalPages}
|
|
|
|
|
>
|
|
|
|
|
다음
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2025-09-01 14:00:31 +09:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 중앙: 캔버스 영역 */}
|
|
|
|
|
<div className="flex-1 bg-white">
|
|
|
|
|
<div className="h-full w-full overflow-auto p-6">
|
|
|
|
|
<div
|
|
|
|
|
className="min-h-full rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-4"
|
2025-09-01 14:26:39 +09:00
|
|
|
onDrop={onDrop}
|
|
|
|
|
onDragOver={onDragOver}
|
2025-09-01 14:00:31 +09:00
|
|
|
>
|
|
|
|
|
{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]">
|
2025-09-01 11:48:12 +09:00
|
|
|
{/* 그리드 가이드 */}
|
|
|
|
|
<div className="pointer-events-none absolute inset-0">
|
2025-09-01 14:00:31 +09:00
|
|
|
<div className="grid h-full grid-cols-12 gap-1">
|
2025-09-01 11:48:12 +09:00
|
|
|
{Array.from({ length: 12 }).map((_, i) => (
|
|
|
|
|
<div key={i} className="border-r border-gray-200 last:border-r-0" />
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2025-09-01 14:00:31 +09:00
|
|
|
{/* 컴포넌트들 */}
|
|
|
|
|
{layout.components.map((component) => (
|
|
|
|
|
<div
|
|
|
|
|
key={component.id}
|
2025-09-01 14:26:39 +09:00
|
|
|
className={`absolute cursor-move rounded border-2 ${
|
|
|
|
|
selectedComponent?.id === component.id
|
|
|
|
|
? "border-blue-500 bg-blue-50"
|
|
|
|
|
: "border-gray-300 bg-white hover:border-gray-400"
|
2025-09-01 14:00:31 +09:00
|
|
|
}`}
|
|
|
|
|
style={{
|
2025-09-01 14:26:39 +09:00
|
|
|
left: `${component.position.x}px`,
|
|
|
|
|
top: `${component.position.y}px`,
|
2025-09-01 14:00:31 +09:00
|
|
|
width: `${component.size.width * 80 - 16}px`,
|
|
|
|
|
height: `${component.size.height}px`,
|
|
|
|
|
}}
|
2025-09-01 14:26:39 +09:00
|
|
|
onClick={() => handleComponentClick(component)}
|
|
|
|
|
draggable
|
|
|
|
|
onDragStart={(e) => startComponentDrag(component, e)}
|
|
|
|
|
onDragEnd={endDrag}
|
2025-09-01 14:00:31 +09:00
|
|
|
>
|
2025-09-01 14:26:39 +09:00
|
|
|
<div className="flex h-full items-center justify-center p-2">
|
2025-09-01 14:00:31 +09:00
|
|
|
{component.type === "container" && (
|
2025-09-01 14:26:39 +09:00
|
|
|
<div className="flex flex-col items-center space-y-1">
|
|
|
|
|
<Database className="h-6 w-6 text-blue-600" />
|
2025-09-01 14:00:31 +09:00
|
|
|
<div className="text-center">
|
2025-09-01 14:26:39 +09:00
|
|
|
<div className="text-sm font-medium">{component.label}</div>
|
2025-09-01 14:00:31 +09:00
|
|
|
<div className="text-xs text-gray-500">{component.tableName}</div>
|
|
|
|
|
</div>
|
2025-09-01 14:26:39 +09:00
|
|
|
</div>
|
2025-09-01 14:00:31 +09:00
|
|
|
)}
|
|
|
|
|
{component.type === "widget" && (
|
2025-09-01 14:26:39 +09:00
|
|
|
<div className="flex flex-col items-center space-y-1">
|
|
|
|
|
{component.widgetType === "text" && <Type className="h-6 w-6 text-blue-600" />}
|
|
|
|
|
{component.widgetType === "number" && <Hash className="h-6 w-6 text-green-600" />}
|
|
|
|
|
{component.widgetType === "date" && <Calendar className="h-6 w-6 text-purple-600" />}
|
|
|
|
|
{component.widgetType === "select" && <List className="h-6 w-6 text-orange-600" />}
|
|
|
|
|
{component.widgetType === "textarea" && <AlignLeft className="h-6 w-6 text-indigo-600" />}
|
|
|
|
|
{component.widgetType === "checkbox" && <CheckSquare className="h-6 w-6 text-blue-600" />}
|
|
|
|
|
{component.widgetType === "radio" && <Radio className="h-6 w-6 text-blue-600" />}
|
|
|
|
|
{!["text", "number", "date", "select", "textarea", "checkbox", "radio"].includes(
|
|
|
|
|
component.widgetType || "text",
|
|
|
|
|
) && <Type className="h-6 w-6 text-blue-600" />}
|
2025-09-01 14:00:31 +09:00
|
|
|
<div className="text-center">
|
2025-09-01 14:26:39 +09:00
|
|
|
<div className="text-sm font-medium">{component.label}</div>
|
2025-09-01 14:00:31 +09:00
|
|
|
<div className="text-xs text-gray-500">{component.columnName}</div>
|
|
|
|
|
</div>
|
2025-09-01 14:26:39 +09:00
|
|
|
</div>
|
2025-09-01 14:00:31 +09:00
|
|
|
)}
|
2025-09-01 11:48:12 +09:00
|
|
|
</div>
|
|
|
|
|
</div>
|
2025-09-01 14:00:31 +09:00
|
|
|
))}
|
2025-09-01 11:48:12 +09:00
|
|
|
</div>
|
2025-09-01 14:00:31 +09:00
|
|
|
)}
|
|
|
|
|
</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>
|
2025-09-01 11:48:12 +09:00
|
|
|
|
|
|
|
|
{selectedComponent ? (
|
2025-09-01 14:00:31 +09:00
|
|
|
<div className="space-y-4">
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader className="pb-3">
|
|
|
|
|
<CardTitle className="text-sm font-medium">
|
|
|
|
|
{selectedComponent.type === "container" && "테이블 속성"}
|
|
|
|
|
{selectedComponent.type === "widget" && "위젯 속성"}
|
|
|
|
|
</CardTitle>
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent className="space-y-4">
|
|
|
|
|
{/* 위치 속성 */}
|
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label htmlFor="positionX">X 위치</Label>
|
|
|
|
|
<Input
|
|
|
|
|
id="positionX"
|
|
|
|
|
type="number"
|
|
|
|
|
min="0"
|
|
|
|
|
value={selectedComponent.position.x}
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
updateComponentProperty(selectedComponent.id, "position.x", parseInt(e.target.value))
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label htmlFor="positionY">Y 위치</Label>
|
|
|
|
|
<Input
|
|
|
|
|
id="positionY"
|
|
|
|
|
type="number"
|
|
|
|
|
min="0"
|
|
|
|
|
value={selectedComponent.position.y}
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
updateComponentProperty(selectedComponent.id, "position.y", parseInt(e.target.value))
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2025-09-01 11:48:12 +09:00
|
|
|
</div>
|
|
|
|
|
|
2025-09-01 14:00:31 +09:00
|
|
|
{/* 크기 속성 */}
|
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
2025-09-01 11:48:12 +09:00
|
|
|
<div className="space-y-2">
|
2025-09-01 14:00:31 +09:00
|
|
|
<Label htmlFor="width">너비 (컬럼)</Label>
|
2025-09-01 11:48:12 +09:00
|
|
|
<Input
|
2025-09-01 14:00:31 +09:00
|
|
|
id="width"
|
|
|
|
|
type="number"
|
|
|
|
|
min="1"
|
|
|
|
|
max="12"
|
|
|
|
|
value={selectedComponent.size.width}
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
updateComponentProperty(selectedComponent.id, "size.width", parseInt(e.target.value))
|
|
|
|
|
}
|
2025-09-01 11:48:12 +09:00
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="space-y-2">
|
2025-09-01 14:00:31 +09:00
|
|
|
<Label htmlFor="height">높이 (픽셀)</Label>
|
2025-09-01 11:48:12 +09:00
|
|
|
<Input
|
2025-09-01 14:00:31 +09:00
|
|
|
id="height"
|
|
|
|
|
type="number"
|
|
|
|
|
min="20"
|
|
|
|
|
value={selectedComponent.size.height}
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
updateComponentProperty(selectedComponent.id, "size.height", parseInt(e.target.value))
|
|
|
|
|
}
|
2025-09-01 11:48:12 +09:00
|
|
|
/>
|
|
|
|
|
</div>
|
2025-09-01 14:00:31 +09:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 테이블 정보 */}
|
|
|
|
|
<Separator />
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label htmlFor="tableName">테이블명</Label>
|
|
|
|
|
<Input id="tableName" value={selectedComponent.tableName || ""} readOnly className="bg-gray-50" />
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 위젯 전용 속성 */}
|
|
|
|
|
{selectedComponent.type === "widget" && (
|
|
|
|
|
<>
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label htmlFor="columnName">컬럼명</Label>
|
|
|
|
|
<Input
|
|
|
|
|
id="columnName"
|
|
|
|
|
value={selectedComponent.columnName || ""}
|
|
|
|
|
readOnly
|
|
|
|
|
className="bg-gray-50"
|
2025-09-01 11:48:12 +09:00
|
|
|
/>
|
|
|
|
|
</div>
|
2025-09-01 14:00:31 +09:00
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label htmlFor="widgetType">위젯 타입</Label>
|
|
|
|
|
<Input
|
|
|
|
|
id="widgetType"
|
|
|
|
|
value={selectedComponent.widgetType || ""}
|
|
|
|
|
readOnly
|
|
|
|
|
className="bg-gray-50"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label htmlFor="widgetLabel">라벨</Label>
|
|
|
|
|
<Input
|
|
|
|
|
id="widgetLabel"
|
|
|
|
|
value={selectedComponent.label || ""}
|
|
|
|
|
onChange={(e) => updateComponentProperty(selectedComponent.id, "label", e.target.value)}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label htmlFor="widgetPlaceholder">플레이스홀더</Label>
|
|
|
|
|
<Input
|
|
|
|
|
id="widgetPlaceholder"
|
|
|
|
|
value={selectedComponent.placeholder || ""}
|
2025-09-01 11:48:12 +09:00
|
|
|
onChange={(e) =>
|
2025-09-01 14:00:31 +09:00
|
|
|
updateComponentProperty(selectedComponent.id, "placeholder", e.target.value)
|
2025-09-01 11:48:12 +09:00
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2025-09-01 14:00:31 +09:00
|
|
|
<div className="flex items-center space-x-4">
|
|
|
|
|
<div className="flex items-center space-x-2">
|
|
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
id="required"
|
|
|
|
|
checked={selectedComponent.required || false}
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
updateComponentProperty(selectedComponent.id, "required", e.target.checked)
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
<Label htmlFor="required">필수</Label>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center space-x-2">
|
|
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
id="readonly"
|
|
|
|
|
checked={selectedComponent.readonly || false}
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
updateComponentProperty(selectedComponent.id, "readonly", e.target.checked)
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
<Label htmlFor="readonly">읽기 전용</Label>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 스타일 속성 */}
|
|
|
|
|
<Separator />
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-sm font-medium">스타일 편집</Label>
|
|
|
|
|
<StyleEditor
|
|
|
|
|
style={selectedComponent.style || {}}
|
|
|
|
|
onStyleChange={(newStyle) => updateComponentProperty(selectedComponent.id, "style", newStyle)}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 고급 속성 */}
|
|
|
|
|
<Separator />
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label htmlFor="parentId">부모 ID</Label>
|
|
|
|
|
<Input id="parentId" value={selectedComponent.parentId || ""} readOnly className="bg-gray-50" />
|
|
|
|
|
</div>
|
|
|
|
|
<Button
|
|
|
|
|
variant="destructive"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => removeComponent(selectedComponent.id)}
|
|
|
|
|
className="w-full"
|
|
|
|
|
>
|
|
|
|
|
<Trash2 className="mr-2 h-4 w-4" />
|
|
|
|
|
컴포넌트 삭제
|
|
|
|
|
</Button>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
</div>
|
2025-09-01 11:48:12 +09:00
|
|
|
) : (
|
|
|
|
|
<div className="py-8 text-center text-gray-500">
|
2025-09-01 14:00:31 +09:00
|
|
|
<Settings className="mx-auto mb-2 h-12 w-12 text-gray-300" />
|
2025-09-01 11:48:12 +09:00
|
|
|
<p>컴포넌트를 선택하여 속성을 편집하세요</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2025-09-01 14:00:31 +09:00
|
|
|
</div>
|
|
|
|
|
</div>
|
2025-09-01 11:48:12 +09:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|