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

1406 lines
58 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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 { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { ChevronDown } from "lucide-react";
import { Settings, Move, Type, Trash2, Copy, Group, Ungroup } from "lucide-react";
import {
ComponentData,
WebType,
WidgetComponent,
GroupComponent,
DataTableComponent,
AreaComponent,
AreaLayoutType,
TableInfo,
} from "@/types/screen";
import { ColumnSpanPreset, COLUMN_SPAN_PRESETS, COLUMN_SPAN_VALUES } from "@/lib/constants/columnSpans";
import { cn } from "@/lib/utils";
import DataTableConfigPanel from "./DataTableConfigPanel";
import { useWebTypes } from "@/hooks/admin/useWebTypes";
import {
BaseInputType,
BASE_INPUT_TYPE_OPTIONS,
getBaseInputType,
getDefaultDetailType,
} from "@/types/input-type-mapping";
import { ColorPickerWithTransparent } from "../common/ColorPickerWithTransparent";
// 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 };
};
gridSettings?: {
columns: number;
gap: number;
padding: number;
snapToGrid: boolean;
};
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,
gridSettings,
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",
labelText: selectedComponent?.style?.labelText || selectedComponent?.label || "",
labelFontSize: selectedComponent?.style?.labelFontSize || "12px",
labelColor: selectedComponent?.style?.labelColor || "#212121",
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",
});
// 너비 드롭다운 로컬 상태 - 실시간 업데이트를 위한 별도 관리
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에서 계산
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;
}
}
console.log("🎯 calculateWidthSpan - width% :", { width, percent, closestSpan });
return closestSpan;
}
return "half";
};
const [localWidthSpan, setLocalWidthSpan] = useState<string>(() =>
calculateWidthSpan(selectedComponent?.style?.width, (selectedComponent as any)?.gridColumns),
);
// 컴포넌트 또는 style.width, gridColumns가 변경될 때 로컬 상태 업데이트
useEffect(() => {
const newSpan = calculateWidthSpan(selectedComponent?.style?.width, (selectedComponent as any)?.gridColumns);
setLocalWidthSpan(newSpan);
console.log("🔄 localWidthSpan :", {
gridColumns: (selectedComponent as any)?.gridColumns,
width: selectedComponent?.style?.width,
newSpan,
});
}, [selectedComponent?.id, selectedComponent?.style?.width, (selectedComponent as any)?.gridColumns]);
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 || "#212121",
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: "#212121",
});
// 로컬 입력 상태도 업데이트
setLocalInputs((prev) => ({
...prev,
labelColor: "#212121",
}));
}
}
}
}, [
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="text-muted-foreground mb-3 h-10 w-10" />
<h3 className="mb-2 text-sm font-semibold">컴포넌트를 선택하세요</h3>
<p className="text-muted-foreground text-xs">
캔버스에서 컴포넌트를 클릭하면
<br />
속성을 편집할 수 있습니다.
</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="text-muted-foreground h-5 w-5" />
<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-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<Settings className="text-muted-foreground h-4 w-4" />
<h3 className="text-sm font-semibold">속성 편집</h3>
</div>
<Badge variant="secondary" className="text-xs font-medium">
{selectedComponent.type}
</Badge>
</div>
{/* 액션 버튼들 */}
<div className="flex flex-wrap gap-1.5">
{canGroup && (
<Button size="sm" variant="outline" onClick={onGroupComponents} className="h-8 px-2.5 text-xs">
<Group className="mr-1 h-3 w-3" />
그룹
</Button>
)}
{canUngroup && (
<Button size="sm" variant="outline" onClick={onUngroupComponents} className="h-8 px-2.5 text-xs">
<Ungroup className="mr-1 h-3 w-3" />
해제
</Button>
)}
</div>
</div>
{/* 속성 편집 영역 */}
<div className="flex-1 space-y-4 overflow-y-auto p-4">
{/* 기본 정보 */}
<div className="space-y-3">
<div className="flex items-center gap-2">
<Type className="text-muted-foreground h-4 w-4" />
<h4 className="text-sm font-semibold">기본 정보</h4>
</div>
<div className="space-y-3">
{(selectedComponent.type === "widget" || selectedComponent.type === "component") && (
<>
<div className="space-y-1.5">
<Label htmlFor="columnName" className="text-xs font-medium">
컬럼명 (필드명)
</Label>
<Input
id="columnName"
value={selectedComponent.columnName || ""}
onChange={(e) => onUpdateProperty("columnName", e.target.value)}
placeholder="formData에서 "
className="h-8"
title=" "
/>
<p className="text-muted-foreground text-xs">
분할 패널에서 데이터를 전달받을 때 매핑되는 필드명
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="inputType" className="text-xs font-medium">
입력 타입
</Label>
<select
className="border-input bg-background focus-visible:ring-ring flex h-8 w-full rounded-md border px-3 py-1 text-xs shadow-sm transition-colors focus-visible:ring-1 focus-visible:outline-none"
value={getBaseInputType(localInputs.widgetType)}
onChange={(e) => {
const selectedInputType = e.target.value as BaseInputType;
const defaultWebType = getDefaultDetailType(selectedInputType);
setLocalInputs((prev) => ({ ...prev, widgetType: defaultWebType }));
onUpdateProperty("widgetType", defaultWebType);
}}
>
{BASE_INPUT_TYPE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label} - {option.description}
</option>
))}
</select>
<p className="text-muted-foreground text-xs">세부 타입은 " " 패널에서 선택하세요</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="placeholder" className="text-xs font-medium">
플레이스홀더
</Label>
<Input
id="placeholder"
value={localInputs.placeholder}
onChange={(e) => {
const newValue = e.target.value;
setLocalInputs((prev) => ({ ...prev, placeholder: newValue }));
onUpdateProperty("placeholder", newValue);
}}
placeholder=" "
className="h-8"
/>
</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-xs">
필수 입력
</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-xs">
읽기 전용
</Label>
</div>
</div>
</>
)}
</div>
</div>
<Separator />
{/* 위치 및 크기 */}
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Move className="text-muted-foreground h-4 w-4" />
<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"
step="1"
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
? "bg-accent border-blue-300 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"
step="1"
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
? "bg-accent border-blue-300 text-blue-700"
: ""
}`}
readOnly={dragState?.isDragging && dragState.draggedComponent?.id === selectedComponent?.id}
/>
</div>
{/* 카드 레이아웃은 자동 크기 계산으로 너비/높이 설정 숨김 */}
{selectedComponent?.type !== "layout" || (selectedComponent as any)?.layoutType !== "card" ? (
<>
{/* 🆕 그리드 컬럼 수 직접 입력 */}
<div className="col-span-2">
<Label htmlFor="gridColumns" className="text-sm font-medium">
차지 컬럼 수
</Label>
<div className="mt-1 flex items-center gap-2">
<Input
id="gridColumns"
type="number"
min={1}
max={gridSettings?.columns || 12}
step="1"
value={(selectedComponent as any)?.gridColumns || 1}
onChange={(e) => {
const value = parseInt(e.target.value, 10);
const maxColumns = gridSettings?.columns || 12;
if (!isNaN(value) && value >= 1 && value <= maxColumns) {
// gridColumns 업데이트
onUpdateProperty("gridColumns", value);
// width를 퍼센트로 계산하여 업데이트
const widthPercent = (value / maxColumns) * 100;
onUpdateProperty("style.width", `${widthPercent}%`);
// localWidthSpan도 업데이트
setLocalWidthSpan(calculateWidthSpan(`${widthPercent}%`, value));
}
}}
className="h-8 text-xs"
/>
<span className="text-muted-foreground text-xs">
/ {gridSettings?.columns || 12}열
</span>
</div>
<p className="text-muted-foreground mt-1 text-xs">
이 컴포넌트가 차지할 그리드 컬럼 수 (1-{gridSettings?.columns || 12})
</p>
</div>
{/* 기존 컬럼 스팬 선택 (width를 퍼센트로 변환) - 참고용 */}
<div className="col-span-2">
<Label className="text-sm font-medium">미리 정의된 너비</Label>
<Select
value={localWidthSpan}
onValueChange={(value) => {
// 컬럼 스팬을 퍼센트로 변환
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);
// 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,
fiveTwelfths: 5,
sevenTwelfths: 7,
fiveSixths: 10,
elevenTwelfths: 11,
};
const gridColumns = columnsMap[value] || 6;
onUpdateProperty("gridColumns", gridColumns);
}}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(COLUMN_SPAN_PRESETS)
.filter(([key]) => {
// auto 제거 및 중복 퍼센트 옵션 제거
// 제거할 옵션: auto, label(=quarter), input(=threeQuarters), medium(=third), large(=twoThirds)
const excludeKeys = ["auto", "label", "input", "medium", "large"];
return !excludeKeys.includes(key);
})
.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>
{/* 시각적 프리뷰 - 기존 UI 유지, localWidthSpan 기반 */}
<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) => {
// 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;
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">
{(() => {
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 컬럼`;
})()}
</p>
</div>
</div>
<div className="col-span-2">
<Label htmlFor="height" className="text-sm font-medium">
최소 높이
</Label>
<div className="mt-1 flex items-center space-x-2">
<Input
id="height"
type="number"
min="10"
max="2000"
step="1"
value={localInputs.height || 40}
onChange={(e) => {
const newHeight = Math.max(10, Number(e.target.value));
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">
높이 자유 조절 (10px ~ 2000px, 1px 단위)
</p>
</div>
</>
) : (
<div className="bg-accent col-span-2 rounded-lg p-3 text-center">
<p className="text-primary text-xs">카드 레이아웃은 자동으로 크기가 계산됩니다</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"
step="1"
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>
</div>
<Separator />
{/* 라벨 스타일 */}
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Type className="text-muted-foreground h-4 w-4" />
<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>
<div className="mt-1">
<ColorPickerWithTransparent
id="labelColor"
value={localInputs.labelColor}
onChange={(value) => {
setLocalInputs((prev) => ({ ...prev, labelColor: value || "" }));
onUpdateProperty("style.labelColor", value);
}}
defaultColor="#212121"
placeholder="#212121"
/>
</div>
</div>
<div>
<Label htmlFor="labelFontWeight" className="text-sm font-medium">
폰트 굵기
</Label>
<select
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"
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="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"
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="text-muted-foreground h-4 w-4" />
<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="text-muted-foreground h-4 w-4" />
<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="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"
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"
step="1"
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"
step="1"
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="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"
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"
step="1"
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="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"
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"
step="1"
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;