ERP-node/frontend/components/screen/panels/PropertiesPanel.tsx

1130 lines
46 KiB
TypeScript

"use client";
import React, { useState, useEffect, useRef } from "react";
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";
// import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; // 임시 비활성화
// import { Checkbox } from "@/components/ui/checkbox";
import { Settings, Move, Type, Trash2, Copy, Group, Ungroup } from "lucide-react";
import {
ComponentData,
WebType,
WidgetComponent,
GroupComponent,
DataTableComponent,
AreaComponent,
AreaLayoutType,
TableInfo,
} from "@/types/screen";
import DataTableConfigPanel from "./DataTableConfigPanel";
import { useWebTypes } from "@/hooks/admin/useWebTypes";
// 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>) => {
// console.log("🔄 DataTable 래퍼 컴포넌트 업데이트:", updates);
// 변경사항이 있는지 확인 (간단한 비교로 성능 향상)
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) {
// console.log("⏭️ 래퍼: 변경사항 없음, 업데이트 스킵");
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;
},
);
interface PropertiesPanelProps {
selectedComponent?: ComponentData;
tables?: TableInfo[];
dragState?: {
isDragging: boolean;
draggedComponent: ComponentData | null;
currentPosition: { x: number; y: number; z: number };
};
onUpdateProperty: (path: string, value: unknown) => void;
onDeleteComponent: () => void;
onCopyComponent: () => void;
onGroupComponents?: () => void;
onUngroupComponents?: () => void;
canGroup?: boolean;
canUngroup?: boolean;
}
// 동적 웹타입 옵션은 컴포넌트 내부에서 useWebTypes 훅으로 가져옵니다
const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
selectedComponent,
tables = [],
dragState,
onUpdateProperty,
onDeleteComponent,
onCopyComponent,
onGroupComponents,
onUngroupComponents,
canGroup = false,
canUngroup = false,
}) => {
// 🔍 디버깅: PropertiesPanel 렌더링 및 dragState 전달 확인
// console.log("📍 PropertiesPanel 렌더링:", {
// renderTime: Date.now(),
// selectedComponentId: selectedComponent?.id,
// dragState: dragState
// ? {
// isDragging: dragState.isDragging,
// draggedComponentId: dragState.draggedComponent?.id,
// currentPosition: dragState.currentPosition,
// dragStateRef: dragState, // 객체 참조 확인
// }
// : "null",
// });
// 동적 웹타입 목록 가져오기 - API에서 직접 조회
const { webTypes, isLoading: isWebTypesLoading } = useWebTypes({ active: "Y" });
// 강제 리렌더링을 위한 state (드래그 중 실시간 업데이트용)
const [forceRender, setForceRender] = useState(0);
// 드래그 상태를 직접 추적하여 리렌더링 강제
const [lastDragPosition, setLastDragPosition] = useState({ x: 0, y: 0 });
// 웹타입 옵션 생성 - 데이터베이스 기반
const webTypeOptions = webTypes.map((webType) => ({
value: webType.web_type as WebType,
label: webType.type_name,
}));
// 데이터테이블 설정 탭 상태를 여기서 관리
const [dataTableActiveTab, setDataTableActiveTab] = useState("basic");
// 최신 값들의 참조를 유지
const selectedComponentRef = useRef(selectedComponent);
const onUpdatePropertyRef = useRef(onUpdateProperty);
// 실시간 위치 계산 (드래그 중일 때는 dragState.currentPosition 사용)
const getCurrentPosition = () => {
if (dragState?.isDragging && dragState.draggedComponent?.id === selectedComponent?.id) {
// console.log("🎯 드래그 중 실시간 위치:", {
// draggedId: dragState.draggedComponent?.id,
// selectedId: selectedComponent?.id,
// currentPosition: dragState.currentPosition,
// });
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();
// 입력 필드들의 로컬 상태 (실시간 타이핑 반영용)
const [localInputs, setLocalInputs] = useState({
placeholder: (selectedComponent?.type === "widget" ? (selectedComponent as WidgetComponent).placeholder : "") || "",
title:
(selectedComponent?.type === "group"
? (selectedComponent as GroupComponent).title
: selectedComponent?.type === "area"
? (selectedComponent as AreaComponent).title
: "") || "",
description: (selectedComponent?.type === "area" ? (selectedComponent as AreaComponent).description : "") || "",
positionX: currentPosition.x.toString(),
positionY: currentPosition.y.toString(),
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"
: selectedComponent?.type === "component" &&
(selectedComponent as any)?.componentConfig?.type === "card-display"
? "8"
: "1"),
labelText: selectedComponent?.style?.labelText || selectedComponent?.label || "",
labelFontSize: selectedComponent?.style?.labelFontSize || "12px",
labelColor: selectedComponent?.style?.labelColor || "#3b83f6",
labelMarginBottom: selectedComponent?.style?.labelMarginBottom || "4px",
required: (selectedComponent?.type === "widget" ? (selectedComponent as WidgetComponent).required : false) || false,
readonly: (selectedComponent?.type === "widget" ? (selectedComponent as WidgetComponent).readonly : false) || false,
labelDisplay: selectedComponent?.style?.labelDisplay ?? true,
// widgetType도 로컬 상태로 관리
widgetType:
(selectedComponent?.type === "widget" ? (selectedComponent as WidgetComponent).widgetType : "text") || "text",
});
useEffect(() => {
selectedComponentRef.current = selectedComponent;
onUpdatePropertyRef.current = onUpdateProperty;
});
// 선택된 컴포넌트가 변경될 때 로컬 입력 상태 업데이트
useEffect(() => {
if (selectedComponent) {
const widget = selectedComponent.type === "widget" ? (selectedComponent as WidgetComponent) : null;
const group = selectedComponent.type === "group" ? (selectedComponent as GroupComponent) : null;
const area = selectedComponent.type === "area" ? (selectedComponent as AreaComponent) : null;
// console.log("🔄 PropertiesPanel: 컴포넌트 변경 감지", {
// 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(),
// });
// 드래그 중이 아닐 때만 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(),
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",
labelColor: selectedComponent?.style?.labelColor || "#3b83f6",
labelMarginBottom: selectedComponent?.style?.labelMarginBottom || "4px",
required: widget?.required || false,
readonly: widget?.readonly || false,
labelDisplay: selectedComponent.style?.labelDisplay ?? true,
// widgetType 동기화
widgetType: widget?.widgetType || "text",
});
// console.log("✅ localInputs 업데이트 완료:", {
// positionX: currentPos.x.toString(),
// positionY: currentPos.y.toString(),
// });
}
}
}, [
selectedComponent?.id, // ID만 감지하여 컴포넌트 변경 시에만 업데이트
selectedComponent?.position.x, // 컴포넌트 실제 위치 변경 감지 (드래그 완료 후)
selectedComponent?.position.y,
selectedComponent?.position.z, // z 위치도 감지
dragState?.isDragging, // 드래그 상태 변경 감지 (드래그 완료 감지용)
dragState?.justFinishedDrag, // 드래그 완료 직후 감지
]);
// 🔴 삭제 액션일 때 라벨 색상 자동 설정
useEffect(() => {
if (selectedComponent && selectedComponent.type === "component") {
// 삭제 액션 감지 로직 (실제 필드명 사용)
const isDeleteAction = () => {
const deleteKeywords = ['삭제', 'delete', 'remove', '제거', 'del'];
return (
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)
)
);
};
// 🔍 디버깅: 컴포넌트 구조 확인
// console.log("🔍 PropertiesPanel 삭제 액션 디버깅:", {
// 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,
// });
// 액션에 따른 라벨 색상 자동 설정
if (isDeleteAction()) {
// 삭제 액션일 때 빨간색으로 설정 (이미 빨간색이 아닌 경우에만)
if (selectedComponent.style?.labelColor !== '#ef4444') {
// console.log("🔴 삭제 액션 감지: 라벨 색상을 빨간색으로 자동 설정");
onUpdateProperty("style", {
...selectedComponent.style,
labelColor: '#ef4444'
});
// 로컬 입력 상태도 업데이트
setLocalInputs(prev => ({
...prev,
labelColor: '#ef4444'
}));
}
} else {
// 다른 액션일 때 기본 파란색으로 리셋 (현재 빨간색인 경우에만)
if (selectedComponent.style?.labelColor === '#ef4444') {
// console.log("🔵 일반 액션 감지: 라벨 색상을 기본 파란색으로 리셋");
onUpdateProperty("style", {
...selectedComponent.style,
labelColor: '#3b83f6'
});
// 로컬 입력 상태도 업데이트
setLocalInputs(prev => ({
...prev,
labelColor: '#3b83f6'
}));
}
}
}
}, [
selectedComponent?.componentConfig?.action?.type,
selectedComponent?.config?.action?.type,
selectedComponent?.webTypeConfig?.actionType,
selectedComponent?.id,
selectedComponent?.style?.labelColor, // 라벨 색상 변경도 감지
JSON.stringify(selectedComponent?.componentConfig), // 전체 componentConfig 변경 감지
onUpdateProperty
]);
// 렌더링 시마다 실행되는 직접적인 드래그 상태 체크
if (dragState?.isDragging && dragState.draggedComponent?.id === selectedComponent?.id) {
// console.log("🎯 렌더링 중 드래그 상태 감지:", {
// isDragging: dragState.isDragging,
// draggedId: dragState.draggedComponent?.id,
// selectedId: selectedComponent?.id,
// currentPosition: dragState.currentPosition,
// });
const newPosition = {
x: dragState.currentPosition.x,
y: dragState.currentPosition.y,
};
// 위치가 변경되었는지 확인
if (lastDragPosition.x !== newPosition.x || lastDragPosition.y !== newPosition.y) {
// console.log("🔄 위치 변경 감지됨:", {
// oldPosition: lastDragPosition,
// newPosition: newPosition,
// });
// 다음 렌더링 사이클에서 업데이트
setTimeout(() => {
setLastDragPosition(newPosition);
setForceRender((prev) => prev + 1);
}, 0);
}
}
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>
);
}
// 데이터 테이블 컴포넌트인 경우 전용 패널 사용
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">
<Settings className="h-5 w-5 text-gray-600" />
<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">
<DataTableConfigPanelWrapper
selectedComponent={selectedComponent as DataTableComponent}
tables={tables}
activeTab={dataTableActiveTab}
onTabChange={setDataTableActiveTab}
onUpdateProperty={onUpdateProperty}
/>
</div>
</div>
);
}
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">
<Settings className="h-4 w-4 text-gray-600" />
<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">
<Type className="h-4 w-4 text-gray-600" />
<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="데이터베이스 컬럼명"
className="mt-1 bg-gray-50 text-gray-600"
title="컬럼명은 변경할 수 없습니다"
/>
</div>
<div>
<Label htmlFor="widgetType" className="text-sm font-medium">
</Label>
<select
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
value={localInputs.widgetType}
onChange={(e) => {
const value = e.target.value as WebType;
setLocalInputs((prev) => ({ ...prev, widgetType: value }));
onUpdateProperty("widgetType", value);
}}
>
{webTypeOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div>
<Label htmlFor="placeholder" className="text-sm font-medium">
</Label>
<Input
id="placeholder"
value={localInputs.placeholder}
onChange={(e) => {
const newValue = e.target.value;
// console.log("🔄 placeholder 변경:", newValue);
setLocalInputs((prev) => ({ ...prev, placeholder: newValue }));
onUpdateProperty("placeholder", newValue);
}}
placeholder="입력 힌트 텍스트"
className="mt-1"
/>
</div>
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="required"
checked={localInputs.required}
onChange={(e) => {
setLocalInputs((prev) => ({ ...prev, required: e.target.checked }));
onUpdateProperty("required", e.target.checked);
}}
className="border-input bg-background text-primary focus:ring-ring h-4 w-4 rounded border focus:ring-2 focus:ring-offset-2"
/>
<Label htmlFor="required" className="text-sm">
</Label>
</div>
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="readonly"
checked={localInputs.readonly}
onChange={(e) => {
setLocalInputs((prev) => ({ ...prev, readonly: e.target.checked }));
onUpdateProperty("readonly", e.target.checked);
}}
className="border-input bg-background text-primary focus:ring-ring h-4 w-4 rounded border focus:ring-2 focus:ring-offset-2"
/>
<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">
<Move className="h-4 w-4 text-gray-600" />
<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"
value={(() => {
const isDragging = dragState?.isDragging && dragState.draggedComponent?.id === selectedComponent?.id;
if (isDragging) {
const realTimeX = Math.round(dragState.currentPosition.x);
// console.log("🔥 실시간 X 렌더링:", realTimeX, "forceRender:", forceRender);
return realTimeX.toString();
}
return localInputs.positionX;
})()}
onChange={(e) => {
const newValue = e.target.value;
setLocalInputs((prev) => ({ ...prev, positionX: newValue }));
onUpdateProperty("position.x", Number(newValue));
}}
className={`mt-1 ${
dragState?.isDragging && dragState.draggedComponent?.id === selectedComponent?.id
? "border-blue-300 bg-blue-50 text-blue-700"
: ""
}`}
readOnly={dragState?.isDragging && dragState.draggedComponent?.id === selectedComponent?.id}
/>
</div>
<div>
<Label htmlFor="positionY" className="text-sm font-medium">
Y
</Label>
<Input
id="positionY"
type="number"
value={(() => {
const isDragging = dragState?.isDragging && dragState.draggedComponent?.id === selectedComponent?.id;
if (isDragging) {
const realTimeY = Math.round(dragState.currentPosition.y);
// console.log("🔥 실시간 Y 렌더링:", realTimeY, "forceRender:", forceRender);
return realTimeY.toString();
}
return localInputs.positionY;
})()}
onChange={(e) => {
const newValue = e.target.value;
setLocalInputs((prev) => ({ ...prev, positionY: newValue }));
onUpdateProperty("position.y", Number(newValue));
}}
className={`mt-1 ${
dragState?.isDragging && dragState.draggedComponent?.id === selectedComponent?.id
? "border-blue-300 bg-blue-50 text-blue-700"
: ""
}`}
readOnly={dragState?.isDragging && dragState.draggedComponent?.id === selectedComponent?.id}
/>
</div>
{/* 카드 레이아웃은 자동 크기 계산으로 너비/높이 설정 숨김 */}
{selectedComponent?.type !== "layout" || (selectedComponent as any)?.layoutType !== "card" ? (
<>
<div>
<Label htmlFor="width" className="text-sm font-medium">
</Label>
<Input
id="width"
type="number"
value={localInputs.width}
onChange={(e) => {
const newValue = e.target.value;
setLocalInputs((prev) => ({ ...prev, width: newValue }));
onUpdateProperty("size.width", Number(newValue));
}}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="height" className="text-sm font-medium">
</Label>
<Input
id="height"
type="number"
value={localInputs.height}
onChange={(e) => {
const newValue = e.target.value;
setLocalInputs((prev) => ({ ...prev, height: newValue }));
onUpdateProperty("size.height", Number(newValue));
}}
className="mt-1"
/>
</div>
</>
) : (
<div className="col-span-2 rounded-lg bg-blue-50 p-3 text-center">
<p className="text-sm text-blue-600"> </p>
<p className="mt-1 text-xs text-blue-500"> </p>
</div>
)}
<div>
<Label htmlFor="zIndex" className="text-sm font-medium">
Z-Index ( )
</Label>
<Input
id="zIndex"
type="number"
min="0"
max="9999"
value={localInputs.positionZ}
onChange={(e) => {
const newValue = e.target.value;
setLocalInputs((prev) => ({ ...prev, positionZ: newValue }));
onUpdateProperty("position.z", Number(newValue));
}}
className="mt-1"
placeholder="1"
/>
</div>
<div>
<Label htmlFor="gridColumns" className="text-sm font-medium">
(1-12)
</Label>
<Input
id="gridColumns"
type="number"
min="1"
max="12"
value={localInputs.gridColumns}
onChange={(e) => {
const newValue = e.target.value;
const numValue = Number(newValue);
if (numValue >= 1 && numValue <= 12) {
setLocalInputs((prev) => ({ ...prev, gridColumns: newValue }));
onUpdateProperty("gridColumns", numValue);
}
}}
placeholder="1"
className="mt-1"
/>
<div className="mt-1 text-xs text-gray-500">
(기본: 1)
</div>
</div>
</div>
</div>
<Separator />
{/* 라벨 스타일 */}
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Type className="h-4 w-4 text-gray-600" />
<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>
<input
type="checkbox"
id="labelDisplay"
checked={localInputs.labelDisplay}
onChange={(e) => {
// console.log("🔄 라벨 표시 변경:", e.target.checked);
setLocalInputs((prev) => ({ ...prev, labelDisplay: e.target.checked }));
onUpdateProperty("style.labelDisplay", e.target.checked);
}}
className="border-input bg-background text-primary focus:ring-ring h-4 w-4 rounded border focus:ring-2 focus:ring-offset-2"
/>
</div>
{/* 라벨 텍스트 */}
<div>
<Label htmlFor="labelText" className="text-sm font-medium">
</Label>
<Input
id="labelText"
value={localInputs.labelText}
onChange={(e) => {
const newValue = e.target.value;
// console.log("🔄 라벨 텍스트 변경:", newValue);
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>
<select
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
value={selectedComponent.style?.labelFontWeight || "500"}
onChange={(e) => onUpdateProperty("style.labelFontWeight", e.target.value)}
>
<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>
</div>
<div>
<Label htmlFor="labelTextAlign" className="text-sm font-medium">
</Label>
<select
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
value={selectedComponent.style?.labelTextAlign || "left"}
onChange={(e) => onUpdateProperty("style.labelTextAlign", e.target.value)}
>
<option value="left"></option>
<option value="center"></option>
<option value="right"></option>
</select>
</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>
{selectedComponent.type === "group" && (
<>
<Separator />
{/* 그룹 설정 */}
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Group className="h-4 w-4 text-gray-600" />
<h4 className="font-medium text-gray-900"> </h4>
</div>
<div>
<Label htmlFor="groupTitle" className="text-sm font-medium">
</Label>
<Input
id="groupTitle"
value={localInputs.title}
onChange={(e) => {
const newValue = e.target.value;
setLocalInputs((prev) => ({ ...prev, title: newValue }));
onUpdateProperty("title", newValue);
}}
placeholder="그룹 제목"
className="mt-1"
/>
</div>
</div>
</>
)}
{selectedComponent.type === "area" && (
<>
<Separator />
{/* 영역 설정 */}
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Settings className="h-4 w-4 text-gray-600" />
<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>
<select
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
value={(selectedComponent as AreaComponent).layoutType}
onChange={(e) => onUpdateProperty("layoutType", e.target.value as AreaLayoutType)}
>
<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>
</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>
<select
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
value={(selectedComponent as AreaComponent).layoutConfig?.justifyContent || "flex-start"}
onChange={(e) => onUpdateProperty("layoutConfig.justifyContent", e.target.value)}
>
<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>
</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>
<select
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
value={(selectedComponent as AreaComponent).layoutConfig?.sidebarPosition || "left"}
onChange={(e) => onUpdateProperty("layoutConfig.sidebarPosition", e.target.value)}
>
<option value="left"></option>
<option value="right"></option>
</select>
</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>
</>
)}
</div>
</div>
);
};
// 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;
});
export default PropertiesPanel;