2025-09-02 16:18:38 +09:00
|
|
|
"use client";
|
|
|
|
|
|
2025-09-09 15:42:04 +09:00
|
|
|
import React, { useState, useEffect, useRef } from "react";
|
2025-09-02 16:18:38 +09:00
|
|
|
import { Label } from "@/components/ui/label";
|
|
|
|
|
import { Input } from "@/components/ui/input";
|
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
|
import { Badge } from "@/components/ui/badge";
|
|
|
|
|
import { Separator } from "@/components/ui/separator";
|
2025-10-13 18:28:03 +09:00
|
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
|
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
|
|
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
|
|
|
|
import { ChevronDown } from "lucide-react";
|
2025-09-03 11:32:09 +09:00
|
|
|
import { Settings, Move, Type, Trash2, Copy, Group, Ungroup } from "lucide-react";
|
2025-09-08 13:10:09 +09:00
|
|
|
import {
|
|
|
|
|
ComponentData,
|
|
|
|
|
WebType,
|
|
|
|
|
WidgetComponent,
|
|
|
|
|
GroupComponent,
|
|
|
|
|
DataTableComponent,
|
|
|
|
|
AreaComponent,
|
|
|
|
|
AreaLayoutType,
|
|
|
|
|
TableInfo,
|
|
|
|
|
} from "@/types/screen";
|
2025-10-13 18:28:03 +09:00
|
|
|
import { ColumnSpanPreset, COLUMN_SPAN_PRESETS, COLUMN_SPAN_VALUES } from "@/lib/constants/columnSpans";
|
|
|
|
|
import { cn } from "@/lib/utils";
|
2025-09-03 15:23:12 +09:00
|
|
|
import DataTableConfigPanel from "./DataTableConfigPanel";
|
2025-09-09 14:29:04 +09:00
|
|
|
import { useWebTypes } from "@/hooks/admin/useWebTypes";
|
2025-10-14 11:48:04 +09:00
|
|
|
import {
|
|
|
|
|
BaseInputType,
|
|
|
|
|
BASE_INPUT_TYPE_OPTIONS,
|
|
|
|
|
getBaseInputType,
|
|
|
|
|
getDefaultDetailType,
|
|
|
|
|
} from "@/types/input-type-mapping";
|
2025-09-09 14:29:04 +09:00
|
|
|
|
|
|
|
|
// DataTableConfigPanel을 위한 안정화된 래퍼 컴포넌트
|
|
|
|
|
const DataTableConfigPanelWrapper: React.FC<{
|
|
|
|
|
selectedComponent: DataTableComponent;
|
|
|
|
|
tables: TableInfo[];
|
|
|
|
|
activeTab: string;
|
|
|
|
|
onTabChange: (tab: string) => void;
|
|
|
|
|
onUpdateProperty: (property: string, value: any) => void;
|
|
|
|
|
}> = React.memo(
|
|
|
|
|
({ selectedComponent, tables, activeTab, onTabChange, onUpdateProperty }) => {
|
|
|
|
|
// 안정화된 업데이트 핸들러
|
|
|
|
|
const handleUpdateComponent = React.useCallback(
|
|
|
|
|
(updates: Partial<DataTableComponent>) => {
|
2025-10-01 18:17:30 +09:00
|
|
|
// console.log("🔄 DataTable 래퍼 컴포넌트 업데이트:", updates);
|
2025-09-09 14:29:04 +09:00
|
|
|
|
|
|
|
|
// 변경사항이 있는지 확인 (간단한 비교로 성능 향상)
|
|
|
|
|
const hasChanges = Object.entries(updates).some(([key, value]) => {
|
|
|
|
|
const currentValue = (selectedComponent as any)[key];
|
|
|
|
|
// 배열의 경우 길이만 비교
|
|
|
|
|
if (Array.isArray(currentValue) && Array.isArray(value)) {
|
|
|
|
|
return currentValue.length !== value.length;
|
|
|
|
|
}
|
|
|
|
|
// 기본값 비교
|
|
|
|
|
return currentValue !== value;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!hasChanges) {
|
2025-10-01 18:17:30 +09:00
|
|
|
// console.log("⏭️ 래퍼: 변경사항 없음, 업데이트 스킵");
|
2025-09-09 14:29:04 +09:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 각 속성을 개별적으로 업데이트
|
|
|
|
|
Object.entries(updates).forEach(([key, value]) => {
|
|
|
|
|
onUpdateProperty(key, value);
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
[selectedComponent.id, onUpdateProperty],
|
|
|
|
|
); // ID만 의존성으로 사용
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<DataTableConfigPanel
|
|
|
|
|
component={selectedComponent}
|
|
|
|
|
tables={tables}
|
|
|
|
|
activeTab={activeTab}
|
|
|
|
|
onTabChange={onTabChange}
|
|
|
|
|
onUpdateComponent={handleUpdateComponent}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
(prevProps, nextProps) => {
|
|
|
|
|
// 컴포넌트 ID가 다르면 리렌더링
|
|
|
|
|
if (prevProps.selectedComponent.id !== nextProps.selectedComponent.id) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 테이블 목록이 변경되면 리렌더링
|
|
|
|
|
if (prevProps.tables.length !== nextProps.tables.length) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 활성 탭이 변경되면 리렌더링
|
|
|
|
|
if (prevProps.activeTab !== nextProps.activeTab) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 그 외의 경우는 리렌더링하지 않음
|
|
|
|
|
return true;
|
|
|
|
|
},
|
|
|
|
|
);
|
2025-09-02 16:18:38 +09:00
|
|
|
|
|
|
|
|
interface PropertiesPanelProps {
|
|
|
|
|
selectedComponent?: ComponentData;
|
2025-09-03 15:23:12 +09:00
|
|
|
tables?: TableInfo[];
|
2025-09-10 09:50:20 +09:00
|
|
|
dragState?: {
|
|
|
|
|
isDragging: boolean;
|
|
|
|
|
draggedComponent: ComponentData | null;
|
|
|
|
|
currentPosition: { x: number; y: number; z: number };
|
|
|
|
|
};
|
2025-09-03 11:32:09 +09:00
|
|
|
onUpdateProperty: (path: string, value: unknown) => void;
|
2025-09-02 16:18:38 +09:00
|
|
|
onDeleteComponent: () => void;
|
|
|
|
|
onCopyComponent: () => void;
|
|
|
|
|
onGroupComponents?: () => void;
|
|
|
|
|
onUngroupComponents?: () => void;
|
|
|
|
|
canGroup?: boolean;
|
|
|
|
|
canUngroup?: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-09 14:29:04 +09:00
|
|
|
// 동적 웹타입 옵션은 컴포넌트 내부에서 useWebTypes 훅으로 가져옵니다
|
|
|
|
|
|
|
|
|
|
const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
2025-09-02 16:18:38 +09:00
|
|
|
selectedComponent,
|
2025-09-03 15:23:12 +09:00
|
|
|
tables = [],
|
2025-09-10 09:50:20 +09:00
|
|
|
dragState,
|
2025-09-02 16:18:38 +09:00
|
|
|
onUpdateProperty,
|
|
|
|
|
onDeleteComponent,
|
|
|
|
|
onCopyComponent,
|
|
|
|
|
onGroupComponents,
|
|
|
|
|
onUngroupComponents,
|
|
|
|
|
canGroup = false,
|
|
|
|
|
canUngroup = false,
|
|
|
|
|
}) => {
|
2025-09-10 09:50:20 +09:00
|
|
|
// 🔍 디버깅: PropertiesPanel 렌더링 및 dragState 전달 확인
|
2025-10-01 18:17:30 +09:00
|
|
|
// console.log("📍 PropertiesPanel 렌더링:", {
|
2025-10-13 18:28:03 +09:00
|
|
|
// renderTime: Date.now(),
|
|
|
|
|
// selectedComponentId: selectedComponent?.id,
|
|
|
|
|
// dragState: dragState
|
|
|
|
|
// ? {
|
|
|
|
|
// isDragging: dragState.isDragging,
|
|
|
|
|
// draggedComponentId: dragState.draggedComponent?.id,
|
|
|
|
|
// currentPosition: dragState.currentPosition,
|
|
|
|
|
// dragStateRef: dragState, // 객체 참조 확인
|
|
|
|
|
// }
|
|
|
|
|
// : "null",
|
2025-10-01 18:17:30 +09:00
|
|
|
// });
|
2025-09-10 09:50:20 +09:00
|
|
|
|
2025-09-09 14:29:04 +09:00
|
|
|
// 동적 웹타입 목록 가져오기 - API에서 직접 조회
|
|
|
|
|
const { webTypes, isLoading: isWebTypesLoading } = useWebTypes({ active: "Y" });
|
|
|
|
|
|
2025-09-10 09:50:20 +09:00
|
|
|
// 강제 리렌더링을 위한 state (드래그 중 실시간 업데이트용)
|
|
|
|
|
const [forceRender, setForceRender] = useState(0);
|
|
|
|
|
|
|
|
|
|
// 드래그 상태를 직접 추적하여 리렌더링 강제
|
|
|
|
|
const [lastDragPosition, setLastDragPosition] = useState({ x: 0, y: 0 });
|
|
|
|
|
|
2025-09-09 14:29:04 +09:00
|
|
|
// 웹타입 옵션 생성 - 데이터베이스 기반
|
|
|
|
|
const webTypeOptions = webTypes.map((webType) => ({
|
|
|
|
|
value: webType.web_type as WebType,
|
|
|
|
|
label: webType.type_name,
|
|
|
|
|
}));
|
|
|
|
|
|
2025-09-03 18:23:47 +09:00
|
|
|
// 데이터테이블 설정 탭 상태를 여기서 관리
|
|
|
|
|
const [dataTableActiveTab, setDataTableActiveTab] = useState("basic");
|
2025-09-02 16:18:38 +09:00
|
|
|
// 최신 값들의 참조를 유지
|
|
|
|
|
const selectedComponentRef = useRef(selectedComponent);
|
|
|
|
|
const onUpdatePropertyRef = useRef(onUpdateProperty);
|
|
|
|
|
|
2025-09-10 09:50:20 +09:00
|
|
|
// 실시간 위치 계산 (드래그 중일 때는 dragState.currentPosition 사용)
|
|
|
|
|
const getCurrentPosition = () => {
|
|
|
|
|
if (dragState?.isDragging && dragState.draggedComponent?.id === selectedComponent?.id) {
|
2025-10-01 18:17:30 +09:00
|
|
|
// console.log("🎯 드래그 중 실시간 위치:", {
|
2025-10-13 18:28:03 +09:00
|
|
|
// draggedId: dragState.draggedComponent?.id,
|
|
|
|
|
// selectedId: selectedComponent?.id,
|
|
|
|
|
// currentPosition: dragState.currentPosition,
|
2025-10-01 18:17:30 +09:00
|
|
|
// });
|
2025-09-10 09:50:20 +09:00
|
|
|
return {
|
|
|
|
|
x: Math.round(dragState.currentPosition.x),
|
|
|
|
|
y: Math.round(dragState.currentPosition.y),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
x: selectedComponent?.position?.x || 0,
|
|
|
|
|
y: selectedComponent?.position?.y || 0,
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const currentPosition = getCurrentPosition();
|
|
|
|
|
|
2025-09-02 16:46:54 +09:00
|
|
|
// 입력 필드들의 로컬 상태 (실시간 타이핑 반영용)
|
|
|
|
|
const [localInputs, setLocalInputs] = useState({
|
2025-09-03 11:32:09 +09:00
|
|
|
placeholder: (selectedComponent?.type === "widget" ? (selectedComponent as WidgetComponent).placeholder : "") || "",
|
2025-09-08 13:10:09 +09:00
|
|
|
title:
|
|
|
|
|
(selectedComponent?.type === "group"
|
|
|
|
|
? (selectedComponent as GroupComponent).title
|
|
|
|
|
: selectedComponent?.type === "area"
|
|
|
|
|
? (selectedComponent as AreaComponent).title
|
|
|
|
|
: "") || "",
|
|
|
|
|
description: (selectedComponent?.type === "area" ? (selectedComponent as AreaComponent).description : "") || "",
|
2025-09-10 09:50:20 +09:00
|
|
|
positionX: currentPosition.x.toString(),
|
|
|
|
|
positionY: currentPosition.y.toString(),
|
2025-09-02 16:46:54 +09:00
|
|
|
positionZ: selectedComponent?.position.z?.toString() || "1",
|
2025-09-11 16:21:00 +09:00
|
|
|
width: selectedComponent?.size?.width?.toString() || "0",
|
|
|
|
|
height: selectedComponent?.size?.height?.toString() || "0",
|
2025-09-02 16:46:54 +09:00
|
|
|
labelText: selectedComponent?.style?.labelText || selectedComponent?.label || "",
|
|
|
|
|
labelFontSize: selectedComponent?.style?.labelFontSize || "12px",
|
2025-10-02 14:34:15 +09:00
|
|
|
labelColor: selectedComponent?.style?.labelColor || "#212121",
|
2025-09-02 16:46:54 +09:00
|
|
|
labelMarginBottom: selectedComponent?.style?.labelMarginBottom || "4px",
|
2025-09-03 11:32:09 +09:00
|
|
|
required: (selectedComponent?.type === "widget" ? (selectedComponent as WidgetComponent).required : false) || false,
|
|
|
|
|
readonly: (selectedComponent?.type === "widget" ? (selectedComponent as WidgetComponent).readonly : false) || false,
|
2025-09-30 18:42:33 +09:00
|
|
|
labelDisplay: selectedComponent?.style?.labelDisplay ?? true,
|
2025-09-09 14:29:04 +09:00
|
|
|
// widgetType도 로컬 상태로 관리
|
|
|
|
|
widgetType:
|
|
|
|
|
(selectedComponent?.type === "widget" ? (selectedComponent as WidgetComponent).widgetType : "text") || "text",
|
2025-09-02 16:46:54 +09:00
|
|
|
});
|
|
|
|
|
|
2025-10-14 13:27:02 +09:00
|
|
|
// 너비 드롭다운 로컬 상태 - 실시간 업데이트를 위한 별도 관리
|
2025-10-14 16:45:30 +09:00
|
|
|
const calculateWidthSpan = (width: string | number | undefined, gridColumns?: number): string => {
|
|
|
|
|
// gridColumns 값이 있으면 우선 사용
|
|
|
|
|
if (gridColumns) {
|
|
|
|
|
const columnsToSpan: Record<number, string> = {
|
|
|
|
|
1: "twelfth", // 1/12
|
|
|
|
|
2: "small", // 2/12
|
|
|
|
|
3: "quarter", // 3/12
|
|
|
|
|
4: "third", // 4/12
|
|
|
|
|
5: "five-twelfths", // 5/12
|
|
|
|
|
6: "half", // 6/12
|
|
|
|
|
7: "seven-twelfths", // 7/12
|
|
|
|
|
8: "twoThirds", // 8/12
|
|
|
|
|
9: "threeQuarters", // 9/12
|
|
|
|
|
10: "five-sixths", // 10/12
|
|
|
|
|
11: "eleven-twelfths", // 11/12
|
|
|
|
|
12: "full", // 12/12
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const span = columnsToSpan[gridColumns];
|
|
|
|
|
if (span) {
|
|
|
|
|
console.log("🎯 calculateWidthSpan - gridColumns 사용:", { gridColumns, span });
|
|
|
|
|
return span;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// gridColumns가 없으면 style.width에서 계산
|
2025-10-14 13:27:02 +09:00
|
|
|
if (!width) return "half";
|
|
|
|
|
|
|
|
|
|
if (typeof width === "string" && width.includes("%")) {
|
|
|
|
|
const percent = parseFloat(width);
|
|
|
|
|
|
|
|
|
|
// 정확한 매핑을 위해 가장 가까운 값 찾기
|
|
|
|
|
// 중복 제거: small(작게) 사용, third(1/3) 사용, twoThirds(2/3) 사용, quarter(1/4) 사용, threeQuarters(3/4) 사용
|
|
|
|
|
const percentToSpan: Record<number, string> = {
|
|
|
|
|
100: "full", // 12/12
|
|
|
|
|
91.666667: "eleven-twelfths", // 11/12
|
|
|
|
|
83.333333: "five-sixths", // 10/12
|
|
|
|
|
75: "threeQuarters", // 9/12
|
|
|
|
|
66.666667: "twoThirds", // 8/12
|
|
|
|
|
58.333333: "seven-twelfths", // 7/12
|
|
|
|
|
50: "half", // 6/12
|
|
|
|
|
41.666667: "five-twelfths", // 5/12
|
|
|
|
|
33.333333: "third", // 4/12
|
|
|
|
|
25: "quarter", // 3/12
|
|
|
|
|
16.666667: "small", // 2/12
|
|
|
|
|
8.333333: "twelfth", // 1/12
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 가장 가까운 퍼센트 값 찾기 (오차 범위 ±2% 허용)
|
|
|
|
|
let closestSpan = "half";
|
|
|
|
|
let minDiff = Infinity;
|
|
|
|
|
|
|
|
|
|
for (const [key, span] of Object.entries(percentToSpan)) {
|
|
|
|
|
const diff = Math.abs(percent - parseFloat(key));
|
|
|
|
|
if (diff < minDiff && diff < 5) {
|
|
|
|
|
// 5% 오차 범위 내
|
|
|
|
|
minDiff = diff;
|
|
|
|
|
closestSpan = span;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-14 16:45:30 +09:00
|
|
|
console.log("🎯 calculateWidthSpan - width% 사용:", { width, percent, closestSpan });
|
2025-10-14 13:27:02 +09:00
|
|
|
return closestSpan;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return "half";
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const [localWidthSpan, setLocalWidthSpan] = useState<string>(() =>
|
2025-10-14 16:45:30 +09:00
|
|
|
calculateWidthSpan(selectedComponent?.style?.width, (selectedComponent as any)?.gridColumns),
|
2025-10-14 13:27:02 +09:00
|
|
|
);
|
|
|
|
|
|
2025-10-14 16:45:30 +09:00
|
|
|
// 컴포넌트 또는 style.width, gridColumns가 변경될 때 로컬 상태 업데이트
|
2025-10-14 13:27:02 +09:00
|
|
|
useEffect(() => {
|
2025-10-14 16:45:30 +09:00
|
|
|
const newSpan = calculateWidthSpan(selectedComponent?.style?.width, (selectedComponent as any)?.gridColumns);
|
2025-10-14 13:27:02 +09:00
|
|
|
setLocalWidthSpan(newSpan);
|
2025-10-14 16:45:30 +09:00
|
|
|
console.log("🔄 localWidthSpan 업데이트:", {
|
|
|
|
|
gridColumns: (selectedComponent as any)?.gridColumns,
|
|
|
|
|
width: selectedComponent?.style?.width,
|
|
|
|
|
newSpan,
|
|
|
|
|
});
|
|
|
|
|
}, [selectedComponent?.id, selectedComponent?.style?.width, (selectedComponent as any)?.gridColumns]);
|
2025-10-14 13:27:02 +09:00
|
|
|
|
2025-09-02 16:18:38 +09:00
|
|
|
useEffect(() => {
|
|
|
|
|
selectedComponentRef.current = selectedComponent;
|
|
|
|
|
onUpdatePropertyRef.current = onUpdateProperty;
|
|
|
|
|
});
|
|
|
|
|
|
2025-09-02 16:46:54 +09:00
|
|
|
// 선택된 컴포넌트가 변경될 때 로컬 입력 상태 업데이트
|
2025-09-02 16:18:38 +09:00
|
|
|
useEffect(() => {
|
|
|
|
|
if (selectedComponent) {
|
2025-09-03 11:32:09 +09:00
|
|
|
const widget = selectedComponent.type === "widget" ? (selectedComponent as WidgetComponent) : null;
|
|
|
|
|
const group = selectedComponent.type === "group" ? (selectedComponent as GroupComponent) : null;
|
2025-09-08 13:10:09 +09:00
|
|
|
const area = selectedComponent.type === "area" ? (selectedComponent as AreaComponent) : null;
|
2025-09-03 15:23:12 +09:00
|
|
|
|
2025-10-01 18:17:30 +09:00
|
|
|
// console.log("🔄 PropertiesPanel: 컴포넌트 변경 감지", {
|
2025-10-13 18:28:03 +09:00
|
|
|
// componentId: selectedComponent.id,
|
|
|
|
|
// componentType: selectedComponent.type,
|
|
|
|
|
// isDragging: dragState?.isDragging,
|
|
|
|
|
// justFinishedDrag: dragState?.justFinishedDrag,
|
|
|
|
|
// currentValues: {
|
|
|
|
|
// placeholder: widget?.placeholder,
|
|
|
|
|
// title: group?.title || area?.title,
|
|
|
|
|
// description: area?.description,
|
|
|
|
|
// actualPositionX: selectedComponent.position.x,
|
|
|
|
|
// actualPositionY: selectedComponent.position.y,
|
|
|
|
|
// dragPositionX: dragState?.currentPosition.x,
|
|
|
|
|
// dragPositionY: dragState?.currentPosition.y,
|
|
|
|
|
// },
|
|
|
|
|
// getCurrentPosResult: getCurrentPosition(),
|
2025-10-01 18:17:30 +09:00
|
|
|
// });
|
2025-09-03 15:23:12 +09:00
|
|
|
|
2025-09-10 09:50:20 +09:00
|
|
|
// 드래그 중이 아닐 때만 localInputs 업데이트 (드래그 완료 후 최종 위치 반영)
|
|
|
|
|
if (!dragState?.isDragging || dragState.draggedComponent?.id !== selectedComponent.id) {
|
|
|
|
|
const currentPos = getCurrentPosition();
|
|
|
|
|
setLocalInputs({
|
|
|
|
|
placeholder: widget?.placeholder || "",
|
|
|
|
|
title: group?.title || area?.title || "",
|
|
|
|
|
description: area?.description || "",
|
|
|
|
|
positionX: currentPos.x.toString(),
|
|
|
|
|
positionY: currentPos.y.toString(),
|
2025-09-11 16:21:00 +09:00
|
|
|
positionZ: selectedComponent?.position?.z?.toString() || "1",
|
|
|
|
|
width: selectedComponent?.size?.width?.toString() || "0", // 안전한 접근
|
|
|
|
|
height: selectedComponent?.size?.height?.toString() || "0", // 안전한 접근
|
|
|
|
|
gridColumns:
|
|
|
|
|
selectedComponent?.gridColumns?.toString() ||
|
|
|
|
|
(selectedComponent?.type === "layout" && (selectedComponent as any)?.layoutType === "card-layout"
|
|
|
|
|
? "8"
|
|
|
|
|
: "1"),
|
|
|
|
|
labelText: selectedComponent?.style?.labelText || selectedComponent?.label || "",
|
|
|
|
|
labelFontSize: selectedComponent?.style?.labelFontSize || "12px",
|
2025-10-02 14:34:15 +09:00
|
|
|
labelColor: selectedComponent?.style?.labelColor || "#212121",
|
2025-09-11 16:21:00 +09:00
|
|
|
labelMarginBottom: selectedComponent?.style?.labelMarginBottom || "4px",
|
2025-09-10 09:50:20 +09:00
|
|
|
required: widget?.required || false,
|
|
|
|
|
readonly: widget?.readonly || false,
|
2025-09-30 18:42:33 +09:00
|
|
|
labelDisplay: selectedComponent.style?.labelDisplay ?? true,
|
2025-09-10 09:50:20 +09:00
|
|
|
// widgetType 동기화
|
|
|
|
|
widgetType: widget?.widgetType || "text",
|
|
|
|
|
});
|
|
|
|
|
|
2025-10-01 18:17:30 +09:00
|
|
|
// console.log("✅ localInputs 업데이트 완료:", {
|
2025-10-13 18:28:03 +09:00
|
|
|
// positionX: currentPos.x.toString(),
|
|
|
|
|
// positionY: currentPos.y.toString(),
|
2025-10-01 18:17:30 +09:00
|
|
|
// });
|
2025-09-10 09:50:20 +09:00
|
|
|
}
|
2025-09-02 16:18:38 +09:00
|
|
|
}
|
2025-09-03 15:23:12 +09:00
|
|
|
}, [
|
2025-09-09 14:29:04 +09:00
|
|
|
selectedComponent?.id, // ID만 감지하여 컴포넌트 변경 시에만 업데이트
|
2025-09-10 09:50:20 +09:00
|
|
|
selectedComponent?.position.x, // 컴포넌트 실제 위치 변경 감지 (드래그 완료 후)
|
|
|
|
|
selectedComponent?.position.y,
|
|
|
|
|
selectedComponent?.position.z, // z 위치도 감지
|
|
|
|
|
dragState?.isDragging, // 드래그 상태 변경 감지 (드래그 완료 감지용)
|
|
|
|
|
dragState?.justFinishedDrag, // 드래그 완료 직후 감지
|
2025-09-03 15:23:12 +09:00
|
|
|
]);
|
2025-09-02 16:18:38 +09:00
|
|
|
|
2025-09-24 18:07:36 +09:00
|
|
|
// 🔴 삭제 액션일 때 라벨 색상 자동 설정
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (selectedComponent && selectedComponent.type === "component") {
|
|
|
|
|
// 삭제 액션 감지 로직 (실제 필드명 사용)
|
|
|
|
|
const isDeleteAction = () => {
|
2025-10-13 18:28:03 +09:00
|
|
|
const deleteKeywords = ["삭제", "delete", "remove", "제거", "del"];
|
2025-09-24 18:07:36 +09:00
|
|
|
return (
|
2025-10-13 18:28:03 +09:00
|
|
|
selectedComponent.componentConfig?.action?.type === "delete" ||
|
|
|
|
|
selectedComponent.config?.action?.type === "delete" ||
|
|
|
|
|
selectedComponent.webTypeConfig?.actionType === "delete" ||
|
|
|
|
|
selectedComponent.text?.toLowerCase().includes("삭제") ||
|
|
|
|
|
selectedComponent.text?.toLowerCase().includes("delete") ||
|
|
|
|
|
selectedComponent.label?.toLowerCase().includes("삭제") ||
|
|
|
|
|
selectedComponent.label?.toLowerCase().includes("delete") ||
|
|
|
|
|
deleteKeywords.some(
|
|
|
|
|
(keyword) =>
|
|
|
|
|
selectedComponent.config?.buttonText?.toLowerCase().includes(keyword) ||
|
|
|
|
|
selectedComponent.config?.text?.toLowerCase().includes(keyword),
|
2025-09-24 18:07:36 +09:00
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 🔍 디버깅: 컴포넌트 구조 확인
|
2025-10-01 18:17:30 +09:00
|
|
|
// console.log("🔍 PropertiesPanel 삭제 액션 디버깅:", {
|
2025-10-13 18:28:03 +09:00
|
|
|
// componentType: selectedComponent.type,
|
|
|
|
|
// componentId: selectedComponent.id,
|
|
|
|
|
// componentConfig: selectedComponent.componentConfig,
|
|
|
|
|
// config: selectedComponent.config,
|
|
|
|
|
// webTypeConfig: selectedComponent.webTypeConfig,
|
|
|
|
|
// actionType1: selectedComponent.componentConfig?.action?.type,
|
|
|
|
|
// actionType2: selectedComponent.config?.action?.type,
|
|
|
|
|
// actionType3: selectedComponent.webTypeConfig?.actionType,
|
|
|
|
|
// isDeleteAction: isDeleteAction(),
|
|
|
|
|
// currentLabelColor: selectedComponent.style?.labelColor,
|
2025-10-01 18:17:30 +09:00
|
|
|
// });
|
2025-09-24 18:07:36 +09:00
|
|
|
|
|
|
|
|
// 액션에 따른 라벨 색상 자동 설정
|
|
|
|
|
if (isDeleteAction()) {
|
|
|
|
|
// 삭제 액션일 때 빨간색으로 설정 (이미 빨간색이 아닌 경우에만)
|
2025-10-13 18:28:03 +09:00
|
|
|
if (selectedComponent.style?.labelColor !== "#ef4444") {
|
2025-10-01 18:17:30 +09:00
|
|
|
// console.log("🔴 삭제 액션 감지: 라벨 색상을 빨간색으로 자동 설정");
|
2025-09-24 18:07:36 +09:00
|
|
|
onUpdateProperty("style", {
|
|
|
|
|
...selectedComponent.style,
|
2025-10-13 18:28:03 +09:00
|
|
|
labelColor: "#ef4444",
|
2025-09-24 18:07:36 +09:00
|
|
|
});
|
2025-10-13 18:28:03 +09:00
|
|
|
|
2025-09-24 18:07:36 +09:00
|
|
|
// 로컬 입력 상태도 업데이트
|
2025-10-13 18:28:03 +09:00
|
|
|
setLocalInputs((prev) => ({
|
2025-09-24 18:07:36 +09:00
|
|
|
...prev,
|
2025-10-13 18:28:03 +09:00
|
|
|
labelColor: "#ef4444",
|
2025-09-24 18:07:36 +09:00
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// 다른 액션일 때 기본 파란색으로 리셋 (현재 빨간색인 경우에만)
|
2025-10-13 18:28:03 +09:00
|
|
|
if (selectedComponent.style?.labelColor === "#ef4444") {
|
2025-10-01 18:17:30 +09:00
|
|
|
// console.log("🔵 일반 액션 감지: 라벨 색상을 기본 파란색으로 리셋");
|
2025-09-24 18:07:36 +09:00
|
|
|
onUpdateProperty("style", {
|
|
|
|
|
...selectedComponent.style,
|
2025-10-13 18:28:03 +09:00
|
|
|
labelColor: "#212121",
|
2025-09-24 18:07:36 +09:00
|
|
|
});
|
2025-10-13 18:28:03 +09:00
|
|
|
|
2025-09-24 18:07:36 +09:00
|
|
|
// 로컬 입력 상태도 업데이트
|
2025-10-13 18:28:03 +09:00
|
|
|
setLocalInputs((prev) => ({
|
2025-09-24 18:07:36 +09:00
|
|
|
...prev,
|
2025-10-13 18:28:03 +09:00
|
|
|
labelColor: "#212121",
|
2025-09-24 18:07:36 +09:00
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}, [
|
|
|
|
|
selectedComponent?.componentConfig?.action?.type,
|
|
|
|
|
selectedComponent?.config?.action?.type,
|
|
|
|
|
selectedComponent?.webTypeConfig?.actionType,
|
|
|
|
|
selectedComponent?.id,
|
|
|
|
|
selectedComponent?.style?.labelColor, // 라벨 색상 변경도 감지
|
|
|
|
|
JSON.stringify(selectedComponent?.componentConfig), // 전체 componentConfig 변경 감지
|
2025-10-13 18:28:03 +09:00
|
|
|
onUpdateProperty,
|
2025-09-24 18:07:36 +09:00
|
|
|
]);
|
|
|
|
|
|
2025-09-10 09:50:20 +09:00
|
|
|
// 렌더링 시마다 실행되는 직접적인 드래그 상태 체크
|
|
|
|
|
if (dragState?.isDragging && dragState.draggedComponent?.id === selectedComponent?.id) {
|
2025-10-01 18:17:30 +09:00
|
|
|
// console.log("🎯 렌더링 중 드래그 상태 감지:", {
|
2025-10-13 18:28:03 +09:00
|
|
|
// isDragging: dragState.isDragging,
|
|
|
|
|
// draggedId: dragState.draggedComponent?.id,
|
|
|
|
|
// selectedId: selectedComponent?.id,
|
|
|
|
|
// currentPosition: dragState.currentPosition,
|
2025-10-01 18:17:30 +09:00
|
|
|
// });
|
2025-09-10 09:50:20 +09:00
|
|
|
|
|
|
|
|
const newPosition = {
|
|
|
|
|
x: dragState.currentPosition.x,
|
|
|
|
|
y: dragState.currentPosition.y,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 위치가 변경되었는지 확인
|
|
|
|
|
if (lastDragPosition.x !== newPosition.x || lastDragPosition.y !== newPosition.y) {
|
2025-10-01 18:17:30 +09:00
|
|
|
// console.log("🔄 위치 변경 감지됨:", {
|
2025-10-13 18:28:03 +09:00
|
|
|
// oldPosition: lastDragPosition,
|
|
|
|
|
// newPosition: newPosition,
|
2025-10-01 18:17:30 +09:00
|
|
|
// });
|
2025-09-10 09:50:20 +09:00
|
|
|
// 다음 렌더링 사이클에서 업데이트
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
setLastDragPosition(newPosition);
|
|
|
|
|
setForceRender((prev) => prev + 1);
|
|
|
|
|
}, 0);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-02 16:18:38 +09:00
|
|
|
if (!selectedComponent) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex h-full flex-col items-center justify-center p-6 text-center">
|
|
|
|
|
<Settings className="mb-4 h-12 w-12 text-gray-400" />
|
|
|
|
|
<h3 className="mb-2 text-lg font-medium text-gray-900">컴포넌트를 선택하세요</h3>
|
|
|
|
|
<p className="text-sm text-gray-500">캔버스에서 컴포넌트를 클릭하면 속성을 편집할 수 있습니다.</p>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-03 15:23:12 +09:00
|
|
|
// 데이터 테이블 컴포넌트인 경우 전용 패널 사용
|
|
|
|
|
if (selectedComponent.type === "datatable") {
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex h-full flex-col">
|
|
|
|
|
{/* 헤더 */}
|
|
|
|
|
<div className="border-b border-gray-200 p-4">
|
|
|
|
|
<div className="mb-3 flex items-center justify-between">
|
|
|
|
|
<div className="flex items-center space-x-2">
|
2025-10-13 18:28:03 +09:00
|
|
|
<Settings className="text-muted-foreground h-5 w-5" />
|
2025-09-03 15:23:12 +09:00
|
|
|
<span className="text-lg font-semibold">데이터 테이블 설정</span>
|
|
|
|
|
</div>
|
|
|
|
|
<Badge variant="secondary" className="text-xs">
|
|
|
|
|
{selectedComponent.type}
|
|
|
|
|
</Badge>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 액션 버튼들 */}
|
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
|
|
|
<Button variant="outline" size="sm" onClick={onCopyComponent}>
|
|
|
|
|
<Copy className="mr-1 h-4 w-4" />
|
|
|
|
|
복사
|
|
|
|
|
</Button>
|
|
|
|
|
<Button variant="destructive" size="sm" onClick={onDeleteComponent}>
|
|
|
|
|
<Trash2 className="mr-1 h-4 w-4" />
|
|
|
|
|
삭제
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 데이터 테이블 설정 패널 */}
|
|
|
|
|
<div className="flex-1 overflow-y-auto">
|
2025-09-09 14:29:04 +09:00
|
|
|
<DataTableConfigPanelWrapper
|
|
|
|
|
selectedComponent={selectedComponent as DataTableComponent}
|
2025-09-03 15:23:12 +09:00
|
|
|
tables={tables}
|
2025-09-03 18:23:47 +09:00
|
|
|
activeTab={dataTableActiveTab}
|
|
|
|
|
onTabChange={setDataTableActiveTab}
|
2025-09-09 14:29:04 +09:00
|
|
|
onUpdateProperty={onUpdateProperty}
|
2025-09-03 15:23:12 +09:00
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-02 16:18:38 +09:00
|
|
|
return (
|
|
|
|
|
<div className="flex h-full flex-col">
|
|
|
|
|
{/* 헤더 */}
|
|
|
|
|
<div className="border-b border-gray-200 p-4">
|
|
|
|
|
<div className="mb-3 flex items-center justify-between">
|
|
|
|
|
<div className="flex items-center space-x-2">
|
2025-10-13 18:28:03 +09:00
|
|
|
<Settings className="text-muted-foreground h-4 w-4" />
|
2025-09-02 16:18:38 +09:00
|
|
|
<h3 className="font-medium text-gray-900">속성 편집</h3>
|
|
|
|
|
</div>
|
|
|
|
|
<Badge variant="secondary" className="text-xs">
|
|
|
|
|
{selectedComponent.type}
|
|
|
|
|
</Badge>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 액션 버튼들 */}
|
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
|
|
|
<Button size="sm" variant="outline" onClick={onCopyComponent} className="flex items-center space-x-1">
|
|
|
|
|
<Copy className="h-3 w-3" />
|
|
|
|
|
<span>복사</span>
|
|
|
|
|
</Button>
|
|
|
|
|
|
|
|
|
|
{canGroup && (
|
|
|
|
|
<Button size="sm" variant="outline" onClick={onGroupComponents} className="flex items-center space-x-1">
|
|
|
|
|
<Group className="h-3 w-3" />
|
|
|
|
|
<span>그룹</span>
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{canUngroup && (
|
|
|
|
|
<Button size="sm" variant="outline" onClick={onUngroupComponents} className="flex items-center space-x-1">
|
|
|
|
|
<Ungroup className="h-3 w-3" />
|
|
|
|
|
<span>해제</span>
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<Button size="sm" variant="destructive" onClick={onDeleteComponent} className="flex items-center space-x-1">
|
|
|
|
|
<Trash2 className="h-3 w-3" />
|
|
|
|
|
<span>삭제</span>
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 속성 편집 영역 */}
|
|
|
|
|
<div className="flex-1 space-y-6 overflow-y-auto p-4">
|
|
|
|
|
{/* 기본 정보 */}
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
<div className="flex items-center space-x-2">
|
2025-10-13 18:28:03 +09:00
|
|
|
<Type className="text-muted-foreground h-4 w-4" />
|
2025-09-02 16:18:38 +09:00
|
|
|
<h4 className="font-medium text-gray-900">기본 정보</h4>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
{selectedComponent.type === "widget" && (
|
|
|
|
|
<>
|
|
|
|
|
<div>
|
|
|
|
|
<Label htmlFor="columnName" className="text-sm font-medium">
|
|
|
|
|
컬럼명 (읽기 전용)
|
|
|
|
|
</Label>
|
|
|
|
|
<Input
|
|
|
|
|
id="columnName"
|
|
|
|
|
value={selectedComponent.columnName || ""}
|
|
|
|
|
readOnly
|
|
|
|
|
placeholder="데이터베이스 컬럼명"
|
2025-10-13 18:28:03 +09:00
|
|
|
className="text-muted-foreground mt-1 bg-gray-50"
|
2025-09-02 16:18:38 +09:00
|
|
|
title="컬럼명은 변경할 수 없습니다"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
2025-10-14 11:48:04 +09:00
|
|
|
<Label htmlFor="inputType" className="text-sm font-medium">
|
|
|
|
|
입력 타입
|
2025-09-02 16:18:38 +09:00
|
|
|
</Label>
|
2025-09-09 14:29:04 +09:00
|
|
|
<select
|
2025-10-13 18:28:03 +09:00
|
|
|
className="focus:border-primary mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
2025-10-14 11:48:04 +09:00
|
|
|
value={getBaseInputType(localInputs.widgetType)}
|
2025-09-09 14:29:04 +09:00
|
|
|
onChange={(e) => {
|
2025-10-14 11:48:04 +09:00
|
|
|
const selectedInputType = e.target.value as BaseInputType;
|
|
|
|
|
// 입력 타입에 맞는 기본 세부 타입 설정
|
|
|
|
|
const defaultWebType = getDefaultDetailType(selectedInputType);
|
|
|
|
|
setLocalInputs((prev) => ({ ...prev, widgetType: defaultWebType }));
|
|
|
|
|
onUpdateProperty("widgetType", defaultWebType);
|
2025-09-09 14:29:04 +09:00
|
|
|
}}
|
2025-09-02 16:18:38 +09:00
|
|
|
>
|
2025-10-14 11:48:04 +09:00
|
|
|
{BASE_INPUT_TYPE_OPTIONS.map((option) => (
|
2025-09-09 14:29:04 +09:00
|
|
|
<option key={option.value} value={option.value}>
|
2025-10-14 11:48:04 +09:00
|
|
|
{option.label} - {option.description}
|
2025-09-09 14:29:04 +09:00
|
|
|
</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
2025-10-14 11:48:04 +09:00
|
|
|
<p className="mt-1 text-xs text-gray-500">세부 타입은 "상세 설정" 패널에서 선택하세요</p>
|
2025-09-02 16:18:38 +09:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<Label htmlFor="placeholder" className="text-sm font-medium">
|
|
|
|
|
플레이스홀더
|
|
|
|
|
</Label>
|
|
|
|
|
<Input
|
|
|
|
|
id="placeholder"
|
2025-09-02 16:46:54 +09:00
|
|
|
value={localInputs.placeholder}
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
const newValue = e.target.value;
|
2025-10-01 18:17:30 +09:00
|
|
|
// console.log("🔄 placeholder 변경:", newValue);
|
2025-09-02 16:46:54 +09:00
|
|
|
setLocalInputs((prev) => ({ ...prev, placeholder: newValue }));
|
|
|
|
|
onUpdateProperty("placeholder", newValue);
|
|
|
|
|
}}
|
2025-09-02 16:18:38 +09:00
|
|
|
placeholder="입력 힌트 텍스트"
|
|
|
|
|
className="mt-1"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center space-x-4">
|
|
|
|
|
<div className="flex items-center space-x-2">
|
2025-09-09 14:29:04 +09:00
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
2025-09-02 16:18:38 +09:00
|
|
|
id="required"
|
2025-09-02 16:46:54 +09:00
|
|
|
checked={localInputs.required}
|
2025-09-09 14:29:04 +09:00
|
|
|
onChange={(e) => {
|
|
|
|
|
setLocalInputs((prev) => ({ ...prev, required: e.target.checked }));
|
|
|
|
|
onUpdateProperty("required", e.target.checked);
|
2025-09-02 16:46:54 +09:00
|
|
|
}}
|
2025-09-09 14:29:04 +09:00
|
|
|
className="border-input bg-background text-primary focus:ring-ring h-4 w-4 rounded border focus:ring-2 focus:ring-offset-2"
|
2025-09-02 16:18:38 +09:00
|
|
|
/>
|
|
|
|
|
<Label htmlFor="required" className="text-sm">
|
|
|
|
|
필수 입력
|
|
|
|
|
</Label>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center space-x-2">
|
2025-09-09 14:29:04 +09:00
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
2025-09-02 16:18:38 +09:00
|
|
|
id="readonly"
|
2025-09-02 16:46:54 +09:00
|
|
|
checked={localInputs.readonly}
|
2025-09-09 14:29:04 +09:00
|
|
|
onChange={(e) => {
|
|
|
|
|
setLocalInputs((prev) => ({ ...prev, readonly: e.target.checked }));
|
|
|
|
|
onUpdateProperty("readonly", e.target.checked);
|
2025-09-02 16:46:54 +09:00
|
|
|
}}
|
2025-09-09 14:29:04 +09:00
|
|
|
className="border-input bg-background text-primary focus:ring-ring h-4 w-4 rounded border focus:ring-2 focus:ring-offset-2"
|
2025-09-02 16:18:38 +09:00
|
|
|
/>
|
|
|
|
|
<Label htmlFor="readonly" className="text-sm">
|
|
|
|
|
읽기 전용
|
|
|
|
|
</Label>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<Separator />
|
|
|
|
|
|
|
|
|
|
{/* 위치 및 크기 */}
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
<div className="flex items-center space-x-2">
|
2025-10-13 18:28:03 +09:00
|
|
|
<Move className="text-muted-foreground h-4 w-4" />
|
2025-09-02 16:18:38 +09:00
|
|
|
<h4 className="font-medium text-gray-900">위치 및 크기</h4>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
|
|
|
<div>
|
|
|
|
|
<Label htmlFor="positionX" className="text-sm font-medium">
|
|
|
|
|
X 위치
|
|
|
|
|
</Label>
|
|
|
|
|
<Input
|
|
|
|
|
id="positionX"
|
|
|
|
|
type="number"
|
2025-09-10 09:50:20 +09:00
|
|
|
value={(() => {
|
|
|
|
|
const isDragging = dragState?.isDragging && dragState.draggedComponent?.id === selectedComponent?.id;
|
|
|
|
|
if (isDragging) {
|
|
|
|
|
const realTimeX = Math.round(dragState.currentPosition.x);
|
2025-10-01 18:17:30 +09:00
|
|
|
// console.log("🔥 실시간 X 렌더링:", realTimeX, "forceRender:", forceRender);
|
2025-09-10 09:50:20 +09:00
|
|
|
return realTimeX.toString();
|
|
|
|
|
}
|
|
|
|
|
return localInputs.positionX;
|
|
|
|
|
})()}
|
2025-09-02 16:46:54 +09:00
|
|
|
onChange={(e) => {
|
|
|
|
|
const newValue = e.target.value;
|
|
|
|
|
setLocalInputs((prev) => ({ ...prev, positionX: newValue }));
|
2025-09-12 14:24:25 +09:00
|
|
|
onUpdateProperty("position.x", Number(newValue));
|
2025-09-02 16:46:54 +09:00
|
|
|
}}
|
2025-09-10 09:50:20 +09:00
|
|
|
className={`mt-1 ${
|
|
|
|
|
dragState?.isDragging && dragState.draggedComponent?.id === selectedComponent?.id
|
2025-10-13 18:28:03 +09:00
|
|
|
? "bg-accent border-blue-300 text-blue-700"
|
2025-09-10 09:50:20 +09:00
|
|
|
: ""
|
|
|
|
|
}`}
|
|
|
|
|
readOnly={dragState?.isDragging && dragState.draggedComponent?.id === selectedComponent?.id}
|
2025-09-02 16:18:38 +09:00
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<Label htmlFor="positionY" className="text-sm font-medium">
|
|
|
|
|
Y 위치
|
|
|
|
|
</Label>
|
|
|
|
|
<Input
|
|
|
|
|
id="positionY"
|
|
|
|
|
type="number"
|
2025-09-10 09:50:20 +09:00
|
|
|
value={(() => {
|
|
|
|
|
const isDragging = dragState?.isDragging && dragState.draggedComponent?.id === selectedComponent?.id;
|
|
|
|
|
if (isDragging) {
|
|
|
|
|
const realTimeY = Math.round(dragState.currentPosition.y);
|
2025-10-01 18:17:30 +09:00
|
|
|
// console.log("🔥 실시간 Y 렌더링:", realTimeY, "forceRender:", forceRender);
|
2025-09-10 09:50:20 +09:00
|
|
|
return realTimeY.toString();
|
|
|
|
|
}
|
|
|
|
|
return localInputs.positionY;
|
|
|
|
|
})()}
|
2025-09-02 16:46:54 +09:00
|
|
|
onChange={(e) => {
|
|
|
|
|
const newValue = e.target.value;
|
|
|
|
|
setLocalInputs((prev) => ({ ...prev, positionY: newValue }));
|
2025-09-12 14:24:25 +09:00
|
|
|
onUpdateProperty("position.y", Number(newValue));
|
2025-09-02 16:46:54 +09:00
|
|
|
}}
|
2025-09-10 09:50:20 +09:00
|
|
|
className={`mt-1 ${
|
|
|
|
|
dragState?.isDragging && dragState.draggedComponent?.id === selectedComponent?.id
|
2025-10-13 18:28:03 +09:00
|
|
|
? "bg-accent border-blue-300 text-blue-700"
|
2025-09-10 09:50:20 +09:00
|
|
|
: ""
|
|
|
|
|
}`}
|
|
|
|
|
readOnly={dragState?.isDragging && dragState.draggedComponent?.id === selectedComponent?.id}
|
2025-09-02 16:18:38 +09:00
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
2025-09-11 12:22:39 +09:00
|
|
|
{/* 카드 레이아웃은 자동 크기 계산으로 너비/높이 설정 숨김 */}
|
|
|
|
|
{selectedComponent?.type !== "layout" || (selectedComponent as any)?.layoutType !== "card" ? (
|
|
|
|
|
<>
|
2025-10-14 13:27:02 +09:00
|
|
|
{/* 🆕 컬럼 스팬 선택 (width를 퍼센트로 변환) - 기존 UI 유지 */}
|
2025-10-13 18:28:03 +09:00
|
|
|
<div className="col-span-2">
|
|
|
|
|
<Label className="text-sm font-medium">컴포넌트 너비</Label>
|
|
|
|
|
<Select
|
2025-10-14 13:27:02 +09:00
|
|
|
value={localWidthSpan}
|
2025-10-13 18:28:03 +09:00
|
|
|
onValueChange={(value) => {
|
2025-10-14 13:27:02 +09:00
|
|
|
// 컬럼 스팬을 퍼센트로 변환
|
|
|
|
|
const percentages: Record<string, string> = {
|
|
|
|
|
// 표준 옵션 (드롭다운에 표시됨)
|
|
|
|
|
twelfth: "8.333333%", // 1/12
|
|
|
|
|
small: "16.666667%", // 2/12 (작게)
|
|
|
|
|
quarter: "25%", // 3/12 (1/4)
|
|
|
|
|
third: "33.333333%", // 4/12 (1/3)
|
|
|
|
|
"five-twelfths": "41.666667%", // 5/12
|
|
|
|
|
half: "50%", // 6/12 (절반)
|
|
|
|
|
"seven-twelfths": "58.333333%", // 7/12
|
|
|
|
|
twoThirds: "66.666667%", // 8/12 (2/3)
|
|
|
|
|
threeQuarters: "75%", // 9/12 (3/4)
|
|
|
|
|
"five-sixths": "83.333333%", // 10/12
|
|
|
|
|
"eleven-twelfths": "91.666667%", // 11/12
|
|
|
|
|
full: "100%", // 12/12 (전체)
|
|
|
|
|
|
|
|
|
|
// 레거시 호환성 (드롭다운에는 없지만 기존 데이터 지원)
|
|
|
|
|
sixth: "16.666667%", // 2/12 (= small)
|
|
|
|
|
label: "25%", // 3/12 (= quarter)
|
|
|
|
|
medium: "33.333333%", // 4/12 (= third)
|
|
|
|
|
large: "66.666667%", // 8/12 (= twoThirds)
|
|
|
|
|
input: "75%", // 9/12 (= threeQuarters)
|
|
|
|
|
"two-thirds": "66.666667%", // 케밥케이스 호환
|
|
|
|
|
"three-quarters": "75%", // 케밥케이스 호환
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const newWidth = percentages[value] || "50%";
|
|
|
|
|
|
|
|
|
|
// 로컬 상태 즉시 업데이트
|
|
|
|
|
setLocalWidthSpan(value);
|
|
|
|
|
|
|
|
|
|
// 컴포넌트 속성 업데이트
|
|
|
|
|
onUpdateProperty("style.width", newWidth);
|
2025-10-14 16:45:30 +09:00
|
|
|
|
|
|
|
|
// gridColumns도 자동 계산하여 업데이트 (1/12 = 1컬럼, 2/12 = 2컬럼, ...)
|
|
|
|
|
const columnsMap: Record<string, number> = {
|
|
|
|
|
twelfth: 1,
|
|
|
|
|
small: 2,
|
|
|
|
|
quarter: 3,
|
|
|
|
|
third: 4,
|
|
|
|
|
"five-twelfths": 5,
|
|
|
|
|
half: 6,
|
|
|
|
|
"seven-twelfths": 7,
|
|
|
|
|
twoThirds: 8,
|
|
|
|
|
threeQuarters: 9,
|
|
|
|
|
"five-sixths": 10,
|
|
|
|
|
"eleven-twelfths": 11,
|
|
|
|
|
full: 12,
|
|
|
|
|
// 레거시 호환
|
|
|
|
|
sixth: 2,
|
|
|
|
|
label: 3,
|
|
|
|
|
medium: 4,
|
|
|
|
|
large: 8,
|
|
|
|
|
input: 9,
|
|
|
|
|
"two-thirds": 8,
|
|
|
|
|
"three-quarters": 9,
|
2025-10-14 17:40:51 +09:00
|
|
|
fiveTwelfths: 5,
|
|
|
|
|
sevenTwelfths: 7,
|
|
|
|
|
fiveSixths: 10,
|
|
|
|
|
elevenTwelfths: 11,
|
2025-10-14 16:45:30 +09:00
|
|
|
};
|
|
|
|
|
const gridColumns = columnsMap[value] || 6;
|
|
|
|
|
onUpdateProperty("gridColumns", gridColumns);
|
2025-09-11 12:22:39 +09:00
|
|
|
}}
|
2025-10-13 18:28:03 +09:00
|
|
|
>
|
|
|
|
|
<SelectTrigger className="mt-1">
|
|
|
|
|
<SelectValue />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
{Object.entries(COLUMN_SPAN_PRESETS)
|
2025-10-14 13:27:02 +09:00
|
|
|
.filter(([key]) => {
|
|
|
|
|
// auto 제거 및 중복 퍼센트 옵션 제거
|
|
|
|
|
// 제거할 옵션: auto, label(=quarter), input(=threeQuarters), medium(=third), large(=twoThirds)
|
|
|
|
|
const excludeKeys = ["auto", "label", "input", "medium", "large"];
|
|
|
|
|
return !excludeKeys.includes(key);
|
|
|
|
|
})
|
2025-10-13 18:28:03 +09:00
|
|
|
.map(([key, info]) => (
|
|
|
|
|
<SelectItem key={key} value={key}>
|
|
|
|
|
<div className="flex w-full items-center justify-between gap-4">
|
|
|
|
|
<span>{info.label}</span>
|
|
|
|
|
<span className="text-xs text-gray-500">
|
|
|
|
|
{info.value}/12 ({info.percentage})
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
|
2025-10-14 13:27:02 +09:00
|
|
|
{/* 시각적 프리뷰 - 기존 UI 유지, localWidthSpan 기반 */}
|
2025-10-13 18:28:03 +09:00
|
|
|
<div className="mt-3 space-y-2">
|
|
|
|
|
<Label className="text-xs text-gray-500">미리보기</Label>
|
|
|
|
|
<div className="grid h-6 grid-cols-12 gap-0.5 overflow-hidden rounded border">
|
|
|
|
|
{Array.from({ length: 12 }).map((_, i) => {
|
2025-10-14 13:27:02 +09:00
|
|
|
// localWidthSpan으로부터 활성 컬럼 계산
|
|
|
|
|
const spanValues: Record<string, number> = {
|
|
|
|
|
// 표준 옵션
|
|
|
|
|
twelfth: 1,
|
|
|
|
|
small: 2,
|
|
|
|
|
quarter: 3,
|
|
|
|
|
third: 4,
|
|
|
|
|
"five-twelfths": 5,
|
|
|
|
|
half: 6,
|
|
|
|
|
"seven-twelfths": 7,
|
|
|
|
|
twoThirds: 8,
|
|
|
|
|
threeQuarters: 9,
|
|
|
|
|
"five-sixths": 10,
|
|
|
|
|
"eleven-twelfths": 11,
|
|
|
|
|
full: 12,
|
|
|
|
|
|
|
|
|
|
// 레거시 호환성
|
|
|
|
|
sixth: 2,
|
|
|
|
|
label: 3,
|
|
|
|
|
medium: 4,
|
|
|
|
|
large: 8,
|
|
|
|
|
input: 9,
|
|
|
|
|
"two-thirds": 8,
|
|
|
|
|
"three-quarters": 9,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const spanValue = spanValues[localWidthSpan] || 6;
|
|
|
|
|
const isActive = i < spanValue;
|
2025-10-13 18:28:03 +09:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
key={i}
|
|
|
|
|
className={cn("h-full transition-colors", isActive ? "bg-blue-500" : "bg-gray-100")}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
<p className="text-center text-xs text-gray-500">
|
2025-10-14 13:27:02 +09:00
|
|
|
{(() => {
|
|
|
|
|
const spanValues: Record<string, number> = {
|
|
|
|
|
// 표준 옵션
|
|
|
|
|
twelfth: 1,
|
|
|
|
|
small: 2,
|
|
|
|
|
quarter: 3,
|
|
|
|
|
third: 4,
|
|
|
|
|
"five-twelfths": 5,
|
|
|
|
|
half: 6,
|
|
|
|
|
"seven-twelfths": 7,
|
|
|
|
|
twoThirds: 8,
|
|
|
|
|
threeQuarters: 9,
|
|
|
|
|
"five-sixths": 10,
|
|
|
|
|
"eleven-twelfths": 11,
|
|
|
|
|
full: 12,
|
|
|
|
|
|
|
|
|
|
// 레거시 호환성
|
|
|
|
|
sixth: 2,
|
|
|
|
|
label: 3,
|
|
|
|
|
medium: 4,
|
|
|
|
|
large: 8,
|
|
|
|
|
input: 9,
|
|
|
|
|
"two-thirds": 8,
|
|
|
|
|
"three-quarters": 9,
|
|
|
|
|
};
|
|
|
|
|
const cols = spanValues[localWidthSpan] || 6;
|
|
|
|
|
return `${cols} / 12 컬럼`;
|
|
|
|
|
})()}
|
2025-10-13 18:28:03 +09:00
|
|
|
</p>
|
|
|
|
|
</div>
|
2025-09-11 12:22:39 +09:00
|
|
|
</div>
|
2025-09-02 16:18:38 +09:00
|
|
|
|
2025-10-14 17:40:51 +09:00
|
|
|
<div className="col-span-2">
|
2025-09-11 12:22:39 +09:00
|
|
|
<Label htmlFor="height" className="text-sm font-medium">
|
2025-10-14 17:40:51 +09:00
|
|
|
높이 (40px 단위)
|
2025-09-11 12:22:39 +09:00
|
|
|
</Label>
|
2025-10-14 17:40:51 +09:00
|
|
|
<div className="mt-1 flex items-center space-x-2">
|
|
|
|
|
<Input
|
|
|
|
|
id="height"
|
|
|
|
|
type="number"
|
|
|
|
|
min="1"
|
|
|
|
|
max="20"
|
|
|
|
|
value={Math.round((localInputs.height || 40) / 40)}
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
const rows = Math.max(1, Math.min(20, Number(e.target.value)));
|
|
|
|
|
const newHeight = rows * 40;
|
|
|
|
|
setLocalInputs((prev) => ({ ...prev, height: newHeight.toString() }));
|
|
|
|
|
onUpdateProperty("size.height", newHeight);
|
|
|
|
|
}}
|
|
|
|
|
className="flex-1"
|
|
|
|
|
/>
|
|
|
|
|
<span className="text-sm text-gray-500">행 = {localInputs.height || 40}px</span>
|
|
|
|
|
</div>
|
|
|
|
|
<p className="mt-1 text-xs text-gray-500">
|
|
|
|
|
1행 = 40px (현재 {Math.round((localInputs.height || 40) / 40)}행)
|
|
|
|
|
</p>
|
2025-09-11 12:22:39 +09:00
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
2025-10-13 18:28:03 +09:00
|
|
|
<div className="bg-accent col-span-2 rounded-lg p-3 text-center">
|
|
|
|
|
<p className="text-primary text-sm">카드 레이아웃은 자동으로 크기가 계산됩니다</p>
|
2025-09-11 12:22:39 +09:00
|
|
|
<p className="mt-1 text-xs text-blue-500">카드 개수와 간격 설정은 상세설정에서 조정하세요</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2025-09-02 16:18:38 +09:00
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<Label htmlFor="zIndex" className="text-sm font-medium">
|
|
|
|
|
Z-Index (레이어 순서)
|
|
|
|
|
</Label>
|
|
|
|
|
<Input
|
|
|
|
|
id="zIndex"
|
|
|
|
|
type="number"
|
|
|
|
|
min="0"
|
|
|
|
|
max="9999"
|
2025-09-02 16:46:54 +09:00
|
|
|
value={localInputs.positionZ}
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
const newValue = e.target.value;
|
|
|
|
|
setLocalInputs((prev) => ({ ...prev, positionZ: newValue }));
|
2025-09-12 14:24:25 +09:00
|
|
|
onUpdateProperty("position.z", Number(newValue));
|
2025-09-02 16:46:54 +09:00
|
|
|
}}
|
2025-09-02 16:18:38 +09:00
|
|
|
className="mt-1"
|
|
|
|
|
placeholder="1"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2025-09-02 16:46:54 +09:00
|
|
|
<Separator />
|
|
|
|
|
|
|
|
|
|
{/* 라벨 스타일 */}
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
<div className="flex items-center space-x-2">
|
2025-10-13 18:28:03 +09:00
|
|
|
<Type className="text-muted-foreground h-4 w-4" />
|
2025-09-02 16:46:54 +09:00
|
|
|
<h4 className="font-medium text-gray-900">라벨 설정</h4>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 라벨 표시 토글 */}
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<Label htmlFor="labelDisplay" className="text-sm font-medium">
|
|
|
|
|
라벨 표시
|
|
|
|
|
</Label>
|
2025-09-09 14:29:04 +09:00
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
2025-09-02 16:46:54 +09:00
|
|
|
id="labelDisplay"
|
2025-09-03 15:23:12 +09:00
|
|
|
checked={localInputs.labelDisplay}
|
2025-09-09 14:29:04 +09:00
|
|
|
onChange={(e) => {
|
2025-10-01 18:17:30 +09:00
|
|
|
// console.log("🔄 라벨 표시 변경:", e.target.checked);
|
2025-09-09 14:29:04 +09:00
|
|
|
setLocalInputs((prev) => ({ ...prev, labelDisplay: e.target.checked }));
|
|
|
|
|
onUpdateProperty("style.labelDisplay", e.target.checked);
|
2025-09-03 15:23:12 +09:00
|
|
|
}}
|
2025-09-09 14:29:04 +09:00
|
|
|
className="border-input bg-background text-primary focus:ring-ring h-4 w-4 rounded border focus:ring-2 focus:ring-offset-2"
|
2025-09-02 16:46:54 +09:00
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 라벨 텍스트 */}
|
|
|
|
|
<div>
|
|
|
|
|
<Label htmlFor="labelText" className="text-sm font-medium">
|
|
|
|
|
라벨 텍스트
|
|
|
|
|
</Label>
|
|
|
|
|
<Input
|
|
|
|
|
id="labelText"
|
|
|
|
|
value={localInputs.labelText}
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
const newValue = e.target.value;
|
2025-10-01 18:17:30 +09:00
|
|
|
// console.log("🔄 라벨 텍스트 변경:", newValue);
|
2025-09-02 16:46:54 +09:00
|
|
|
setLocalInputs((prev) => ({ ...prev, labelText: newValue }));
|
|
|
|
|
// 기본 라벨과 스타일 라벨을 모두 업데이트
|
|
|
|
|
onUpdateProperty("label", newValue);
|
|
|
|
|
onUpdateProperty("style.labelText", newValue);
|
|
|
|
|
}}
|
|
|
|
|
placeholder="라벨 텍스트"
|
|
|
|
|
className="mt-1"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 라벨 스타일 */}
|
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
|
|
|
<div>
|
|
|
|
|
<Label htmlFor="labelFontSize" className="text-sm font-medium">
|
|
|
|
|
폰트 크기
|
|
|
|
|
</Label>
|
|
|
|
|
<Input
|
|
|
|
|
id="labelFontSize"
|
|
|
|
|
value={localInputs.labelFontSize}
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
const newValue = e.target.value;
|
|
|
|
|
setLocalInputs((prev) => ({ ...prev, labelFontSize: newValue }));
|
|
|
|
|
onUpdateProperty("style.labelFontSize", newValue);
|
|
|
|
|
}}
|
|
|
|
|
placeholder="12px"
|
|
|
|
|
className="mt-1"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<Label htmlFor="labelColor" className="text-sm font-medium">
|
|
|
|
|
색상
|
|
|
|
|
</Label>
|
|
|
|
|
<Input
|
|
|
|
|
id="labelColor"
|
|
|
|
|
type="color"
|
|
|
|
|
value={localInputs.labelColor}
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
const newValue = e.target.value;
|
|
|
|
|
setLocalInputs((prev) => ({ ...prev, labelColor: newValue }));
|
|
|
|
|
onUpdateProperty("style.labelColor", newValue);
|
|
|
|
|
}}
|
|
|
|
|
className="mt-1 h-8"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<Label htmlFor="labelFontWeight" className="text-sm font-medium">
|
|
|
|
|
폰트 굵기
|
|
|
|
|
</Label>
|
2025-09-09 14:29:04 +09:00
|
|
|
<select
|
2025-10-13 18:28:03 +09:00
|
|
|
className="focus:border-primary mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
2025-09-02 16:46:54 +09:00
|
|
|
value={selectedComponent.style?.labelFontWeight || "500"}
|
2025-09-09 14:29:04 +09:00
|
|
|
onChange={(e) => onUpdateProperty("style.labelFontWeight", e.target.value)}
|
2025-09-02 16:46:54 +09:00
|
|
|
>
|
2025-09-09 14:29:04 +09:00
|
|
|
<option value="normal">Normal</option>
|
|
|
|
|
<option value="bold">Bold</option>
|
|
|
|
|
<option value="100">100</option>
|
|
|
|
|
<option value="200">200</option>
|
|
|
|
|
<option value="300">300</option>
|
|
|
|
|
<option value="400">400</option>
|
|
|
|
|
<option value="500">500</option>
|
|
|
|
|
<option value="600">600</option>
|
|
|
|
|
<option value="700">700</option>
|
|
|
|
|
<option value="800">800</option>
|
|
|
|
|
<option value="900">900</option>
|
|
|
|
|
</select>
|
2025-09-02 16:46:54 +09:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<Label htmlFor="labelTextAlign" className="text-sm font-medium">
|
|
|
|
|
텍스트 정렬
|
|
|
|
|
</Label>
|
2025-09-09 14:29:04 +09:00
|
|
|
<select
|
2025-10-13 18:28:03 +09:00
|
|
|
className="focus:border-primary mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
2025-09-02 16:46:54 +09:00
|
|
|
value={selectedComponent.style?.labelTextAlign || "left"}
|
2025-09-09 14:29:04 +09:00
|
|
|
onChange={(e) => onUpdateProperty("style.labelTextAlign", e.target.value)}
|
2025-09-02 16:46:54 +09:00
|
|
|
>
|
2025-09-09 14:29:04 +09:00
|
|
|
<option value="left">왼쪽</option>
|
|
|
|
|
<option value="center">가운데</option>
|
|
|
|
|
<option value="right">오른쪽</option>
|
|
|
|
|
</select>
|
2025-09-02 16:46:54 +09:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 라벨 여백 */}
|
|
|
|
|
<div>
|
|
|
|
|
<Label htmlFor="labelMarginBottom" className="text-sm font-medium">
|
|
|
|
|
라벨 하단 여백
|
|
|
|
|
</Label>
|
|
|
|
|
<Input
|
|
|
|
|
id="labelMarginBottom"
|
|
|
|
|
value={localInputs.labelMarginBottom}
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
const newValue = e.target.value;
|
|
|
|
|
setLocalInputs((prev) => ({ ...prev, labelMarginBottom: newValue }));
|
|
|
|
|
onUpdateProperty("style.labelMarginBottom", newValue);
|
|
|
|
|
}}
|
|
|
|
|
placeholder="4px"
|
|
|
|
|
className="mt-1"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2025-09-02 16:18:38 +09:00
|
|
|
{selectedComponent.type === "group" && (
|
|
|
|
|
<>
|
|
|
|
|
<Separator />
|
|
|
|
|
|
|
|
|
|
{/* 그룹 설정 */}
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
<div className="flex items-center space-x-2">
|
2025-10-13 18:28:03 +09:00
|
|
|
<Group className="text-muted-foreground h-4 w-4" />
|
2025-09-02 16:18:38 +09:00
|
|
|
<h4 className="font-medium text-gray-900">그룹 설정</h4>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<Label htmlFor="groupTitle" className="text-sm font-medium">
|
|
|
|
|
그룹 제목
|
|
|
|
|
</Label>
|
|
|
|
|
<Input
|
|
|
|
|
id="groupTitle"
|
2025-09-02 16:46:54 +09:00
|
|
|
value={localInputs.title}
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
const newValue = e.target.value;
|
|
|
|
|
setLocalInputs((prev) => ({ ...prev, title: newValue }));
|
|
|
|
|
onUpdateProperty("title", newValue);
|
|
|
|
|
}}
|
2025-09-02 16:18:38 +09:00
|
|
|
placeholder="그룹 제목"
|
|
|
|
|
className="mt-1"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
2025-09-08 13:10:09 +09:00
|
|
|
|
|
|
|
|
{selectedComponent.type === "area" && (
|
|
|
|
|
<>
|
|
|
|
|
<Separator />
|
|
|
|
|
|
|
|
|
|
{/* 영역 설정 */}
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
<div className="flex items-center space-x-2">
|
2025-10-13 18:28:03 +09:00
|
|
|
<Settings className="text-muted-foreground h-4 w-4" />
|
2025-09-08 13:10:09 +09:00
|
|
|
<h4 className="font-medium text-gray-900">영역 설정</h4>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<Label htmlFor="areaTitle" className="text-sm font-medium">
|
|
|
|
|
영역 제목
|
|
|
|
|
</Label>
|
|
|
|
|
<Input
|
|
|
|
|
id="areaTitle"
|
|
|
|
|
value={localInputs.title}
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
const newValue = e.target.value;
|
|
|
|
|
setLocalInputs((prev) => ({ ...prev, title: newValue }));
|
|
|
|
|
onUpdateProperty("title", newValue);
|
|
|
|
|
}}
|
|
|
|
|
placeholder="영역 제목"
|
|
|
|
|
className="mt-1"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<Label htmlFor="areaDescription" className="text-sm font-medium">
|
|
|
|
|
영역 설명
|
|
|
|
|
</Label>
|
|
|
|
|
<Input
|
|
|
|
|
id="areaDescription"
|
|
|
|
|
value={localInputs.description}
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
const newValue = e.target.value;
|
|
|
|
|
setLocalInputs((prev) => ({ ...prev, description: newValue }));
|
|
|
|
|
onUpdateProperty("description", newValue);
|
|
|
|
|
}}
|
|
|
|
|
placeholder="영역 설명 (선택사항)"
|
|
|
|
|
className="mt-1"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<Label htmlFor="layoutType" className="text-sm font-medium">
|
|
|
|
|
레이아웃 타입
|
|
|
|
|
</Label>
|
2025-09-09 14:29:04 +09:00
|
|
|
<select
|
2025-10-13 18:28:03 +09:00
|
|
|
className="focus:border-primary mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
2025-09-08 13:10:09 +09:00
|
|
|
value={(selectedComponent as AreaComponent).layoutType}
|
2025-09-09 14:29:04 +09:00
|
|
|
onChange={(e) => onUpdateProperty("layoutType", e.target.value as AreaLayoutType)}
|
2025-09-08 13:10:09 +09:00
|
|
|
>
|
2025-09-09 14:29:04 +09:00
|
|
|
<option value="box">기본 박스</option>
|
|
|
|
|
<option value="card">카드</option>
|
|
|
|
|
<option value="panel">패널 (헤더 포함)</option>
|
|
|
|
|
<option value="section">섹션</option>
|
|
|
|
|
<option value="grid">그리드</option>
|
|
|
|
|
<option value="flex-row">가로 플렉스</option>
|
|
|
|
|
<option value="flex-column">세로 플렉스</option>
|
|
|
|
|
<option value="sidebar">사이드바</option>
|
|
|
|
|
<option value="header-content">헤더-컨텐츠</option>
|
|
|
|
|
<option value="tabs">탭</option>
|
|
|
|
|
<option value="accordion">아코디언</option>
|
|
|
|
|
</select>
|
2025-09-08 13:10:09 +09:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 레이아웃별 상세 설정 */}
|
|
|
|
|
{(selectedComponent as AreaComponent).layoutType === "grid" && (
|
|
|
|
|
<div className="space-y-2 rounded-md border p-3">
|
|
|
|
|
<h5 className="text-sm font-medium">그리드 설정</h5>
|
|
|
|
|
<div className="grid grid-cols-2 gap-2">
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-xs">컬럼 수</Label>
|
|
|
|
|
<Input
|
|
|
|
|
type="number"
|
|
|
|
|
min="1"
|
|
|
|
|
max="12"
|
|
|
|
|
value={(selectedComponent as AreaComponent).layoutConfig?.gridColumns || 3}
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
const value = Number(e.target.value);
|
|
|
|
|
onUpdateProperty("layoutConfig.gridColumns", value);
|
|
|
|
|
}}
|
|
|
|
|
className="mt-1"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-xs">간격 (px)</Label>
|
|
|
|
|
<Input
|
|
|
|
|
type="number"
|
|
|
|
|
min="0"
|
|
|
|
|
value={(selectedComponent as AreaComponent).layoutConfig?.gridGap || 16}
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
const value = Number(e.target.value);
|
|
|
|
|
onUpdateProperty("layoutConfig.gridGap", value);
|
|
|
|
|
}}
|
|
|
|
|
className="mt-1"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{((selectedComponent as AreaComponent).layoutType === "flex-row" ||
|
|
|
|
|
(selectedComponent as AreaComponent).layoutType === "flex-column") && (
|
|
|
|
|
<div className="space-y-2 rounded-md border p-3">
|
|
|
|
|
<h5 className="text-sm font-medium">플렉스 설정</h5>
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-xs">정렬 방식</Label>
|
2025-09-09 14:29:04 +09:00
|
|
|
<select
|
2025-10-13 18:28:03 +09:00
|
|
|
className="focus:border-primary mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
2025-09-08 13:10:09 +09:00
|
|
|
value={(selectedComponent as AreaComponent).layoutConfig?.justifyContent || "flex-start"}
|
2025-09-09 14:29:04 +09:00
|
|
|
onChange={(e) => onUpdateProperty("layoutConfig.justifyContent", e.target.value)}
|
2025-09-08 13:10:09 +09:00
|
|
|
>
|
2025-09-09 14:29:04 +09:00
|
|
|
<option value="flex-start">시작</option>
|
|
|
|
|
<option value="flex-end">끝</option>
|
|
|
|
|
<option value="center">가운데</option>
|
|
|
|
|
<option value="space-between">양끝 정렬</option>
|
|
|
|
|
<option value="space-around">균등 분배</option>
|
|
|
|
|
<option value="space-evenly">균등 간격</option>
|
|
|
|
|
</select>
|
2025-09-08 13:10:09 +09:00
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-xs">간격 (px)</Label>
|
|
|
|
|
<Input
|
|
|
|
|
type="number"
|
|
|
|
|
min="0"
|
|
|
|
|
value={(selectedComponent as AreaComponent).layoutConfig?.gap || 16}
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
const value = Number(e.target.value);
|
|
|
|
|
onUpdateProperty("layoutConfig.gap", value);
|
|
|
|
|
}}
|
|
|
|
|
className="mt-1"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{(selectedComponent as AreaComponent).layoutType === "sidebar" && (
|
|
|
|
|
<div className="space-y-2 rounded-md border p-3">
|
|
|
|
|
<h5 className="text-sm font-medium">사이드바 설정</h5>
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-xs">사이드바 위치</Label>
|
2025-09-09 14:29:04 +09:00
|
|
|
<select
|
2025-10-13 18:28:03 +09:00
|
|
|
className="focus:border-primary mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
2025-09-08 13:10:09 +09:00
|
|
|
value={(selectedComponent as AreaComponent).layoutConfig?.sidebarPosition || "left"}
|
2025-09-09 14:29:04 +09:00
|
|
|
onChange={(e) => onUpdateProperty("layoutConfig.sidebarPosition", e.target.value)}
|
2025-09-08 13:10:09 +09:00
|
|
|
>
|
2025-09-09 14:29:04 +09:00
|
|
|
<option value="left">왼쪽</option>
|
|
|
|
|
<option value="right">오른쪽</option>
|
|
|
|
|
</select>
|
2025-09-08 13:10:09 +09:00
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-xs">사이드바 너비 (px)</Label>
|
|
|
|
|
<Input
|
|
|
|
|
type="number"
|
|
|
|
|
min="100"
|
|
|
|
|
value={(selectedComponent as AreaComponent).layoutConfig?.sidebarWidth || 200}
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
const value = Number(e.target.value);
|
|
|
|
|
onUpdateProperty("layoutConfig.sidebarWidth", value);
|
|
|
|
|
}}
|
|
|
|
|
className="mt-1"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
2025-09-02 16:18:38 +09:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
2025-09-09 14:29:04 +09:00
|
|
|
// React.memo로 감싸서 불필요한 리렌더링 방지
|
|
|
|
|
export const PropertiesPanel = React.memo(PropertiesPanelComponent, (prevProps, nextProps) => {
|
|
|
|
|
// 선택된 컴포넌트 ID가 다르면 리렌더링
|
|
|
|
|
if (prevProps.selectedComponent?.id !== nextProps.selectedComponent?.id) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 선택된 컴포넌트가 없는 상태에서 있는 상태로 변경되거나 그 반대인 경우
|
|
|
|
|
if (!prevProps.selectedComponent !== !nextProps.selectedComponent) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 테이블 목록이 변경되면 리렌더링
|
|
|
|
|
if (prevProps.tables.length !== nextProps.tables.length) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 그룹 관련 props가 변경되면 리렌더링
|
|
|
|
|
if (prevProps.canGroup !== nextProps.canGroup || prevProps.canUngroup !== nextProps.canUngroup) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 그 외의 경우는 리렌더링하지 않음
|
|
|
|
|
return true;
|
|
|
|
|
});
|
|
|
|
|
|
2025-09-02 16:18:38 +09:00
|
|
|
export default PropertiesPanel;
|