ERP-node/frontend/components/screen/ScreenDesigner_new.tsx

679 lines
21 KiB
TypeScript

"use client";
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { Group, Database, Trash2, Copy, Clipboard } from "lucide-react";
import {
ScreenDefinition,
ComponentData,
LayoutData,
GroupState,
WebType,
TableInfo,
GroupComponent,
Position,
} from "@/types/screen";
import { generateComponentId } from "@/lib/utils/generateId";
import {
createGroupComponent,
calculateBoundingBox,
calculateRelativePositions,
restoreAbsolutePositions,
getGroupChildren,
} from "@/lib/utils/groupingUtils";
import {
calculateGridInfo,
snapToGrid,
snapSizeToGrid,
generateGridLines,
GridSettings as GridUtilSettings,
} from "@/lib/utils/gridUtils";
import { GroupingToolbar } from "./GroupingToolbar";
import { screenApi } from "@/lib/api/screen";
import { toast } from "sonner";
import StyleEditor from "./StyleEditor";
import { RealtimePreview } from "./RealtimePreview";
import FloatingPanel from "./FloatingPanel";
import DesignerToolbar from "./DesignerToolbar";
import TablesPanel from "./panels/TablesPanel";
import PropertiesPanel from "./panels/PropertiesPanel";
import GridPanel from "./panels/GridPanel";
import { usePanelState, PanelConfig } from "@/hooks/usePanelState";
interface ScreenDesignerProps {
selectedScreen: ScreenDefinition | null;
onBackToList: () => void;
}
// 패널 설정
const panelConfigs: PanelConfig[] = [
{
id: "tables",
title: "테이블 목록",
defaultPosition: "left",
defaultWidth: 320,
defaultHeight: 600,
shortcutKey: "t",
},
{
id: "properties",
title: "속성 편집",
defaultPosition: "right",
defaultWidth: 320,
defaultHeight: 500,
shortcutKey: "p",
},
{
id: "styles",
title: "스타일 편집",
defaultPosition: "right",
defaultWidth: 320,
defaultHeight: 400,
shortcutKey: "s",
},
{
id: "grid",
title: "격자 설정",
defaultPosition: "right",
defaultWidth: 280,
defaultHeight: 450,
shortcutKey: "g",
},
];
export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenDesignerProps) {
// 패널 상태 관리
const { panelStates, togglePanel, openPanel, closePanel, closeAllPanels } = usePanelState(panelConfigs);
const [layout, setLayout] = useState<LayoutData>({
components: [],
gridSettings: { columns: 12, gap: 16, padding: 16, snapToGrid: true, showGrid: true },
});
const [isSaving, setIsSaving] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [selectedComponent, setSelectedComponent] = useState<ComponentData | null>(null);
// 실행취소/다시실행을 위한 히스토리 상태
const [history, setHistory] = useState<LayoutData[]>([]);
const [historyIndex, setHistoryIndex] = useState(-1);
// 그룹 상태
const [groupState, setGroupState] = useState<GroupState>({
selectedComponents: [],
isGrouping: false,
});
// 드래그 상태
const [dragState, setDragState] = useState({
isDragging: false,
draggedComponent: null as ComponentData | null,
originalPosition: { x: 0, y: 0 },
currentPosition: { x: 0, y: 0 },
grabOffset: { x: 0, y: 0 },
});
// 테이블 데이터
const [tables, setTables] = useState<TableInfo[]>([]);
const [searchTerm, setSearchTerm] = useState("");
// 클립보드
const [clipboard, setClipboard] = useState<{
type: "single" | "multiple" | "group";
data: ComponentData[];
} | null>(null);
// 그룹 생성 다이얼로그
const [showGroupCreateDialog, setShowGroupCreateDialog] = useState(false);
const canvasRef = useRef<HTMLDivElement>(null);
// 격자 정보 계산
const gridInfo = useMemo(() => {
if (!canvasRef.current || !layout.gridSettings) return null;
return calculateGridInfo(canvasRef.current, layout.gridSettings);
}, [layout.gridSettings]);
// 격자 라인 생성
const gridLines = useMemo(() => {
if (!gridInfo || !layout.gridSettings?.showGrid) return [];
return generateGridLines(gridInfo, layout.gridSettings);
}, [gridInfo, layout.gridSettings]);
// 필터된 테이블 목록
const filteredTables = useMemo(() => {
if (!searchTerm) return tables;
return tables.filter(
(table) =>
table.tableName.toLowerCase().includes(searchTerm.toLowerCase()) ||
table.columns.some((col) => col.columnName.toLowerCase().includes(searchTerm.toLowerCase())),
);
}, [tables, searchTerm]);
// 히스토리에 저장
const saveToHistory = useCallback(
(newLayout: LayoutData) => {
setHistory((prev) => {
const newHistory = prev.slice(0, historyIndex + 1);
newHistory.push(newLayout);
return newHistory.slice(-50); // 최대 50개까지만 저장
});
setHistoryIndex((prev) => Math.min(prev + 1, 49));
setHasUnsavedChanges(true);
},
[historyIndex],
);
// 실행취소
const undo = useCallback(() => {
if (historyIndex > 0) {
setHistoryIndex((prev) => prev - 1);
setLayout(history[historyIndex - 1]);
}
}, [history, historyIndex]);
// 다시실행
const redo = useCallback(() => {
if (historyIndex < history.length - 1) {
setHistoryIndex((prev) => prev + 1);
setLayout(history[historyIndex + 1]);
}
}, [history, historyIndex]);
// 컴포넌트 속성 업데이트
const updateComponentProperty = useCallback(
(componentId: string, path: string, value: any) => {
const pathParts = path.split(".");
const updatedComponents = layout.components.map((comp) => {
if (comp.id !== componentId) return comp;
const newComp = { ...comp };
let current: any = newComp;
for (let i = 0; i < pathParts.length - 1; i++) {
if (!current[pathParts[i]]) {
current[pathParts[i]] = {};
}
current = current[pathParts[i]];
}
current[pathParts[pathParts.length - 1]] = value;
// 크기 변경 시 격자 스냅 적용
if ((path === "size.width" || path === "size.height") && layout.gridSettings?.snapToGrid && gridInfo) {
const snappedSize = snapSizeToGrid(newComp.size, gridInfo, layout.gridSettings as GridUtilSettings);
newComp.size = snappedSize;
}
return newComp;
});
const newLayout = { ...layout, components: updatedComponents };
setLayout(newLayout);
saveToHistory(newLayout);
},
[layout, gridInfo, saveToHistory],
);
// 테이블 데이터 로드
useEffect(() => {
if (selectedScreen?.tableName) {
const loadTables = async () => {
try {
setIsLoading(true);
const response = await screenApi.getTableInfo([selectedScreen.tableName]);
setTables(response.data || []);
} catch (error) {
// console.error("테이블 정보 로드 실패:", error);
toast.error("테이블 정보를 불러오는데 실패했습니다.");
} finally {
setIsLoading(false);
}
};
loadTables();
}
}, [selectedScreen?.tableName]);
// 화면 레이아웃 로드
useEffect(() => {
if (selectedScreen?.screenId) {
const loadLayout = async () => {
try {
setIsLoading(true);
const response = await screenApi.getScreenLayout(selectedScreen.screenId);
if (response.success && response.data) {
setLayout(response.data);
setHistory([response.data]);
setHistoryIndex(0);
setHasUnsavedChanges(false);
}
} catch (error) {
// console.error("레이아웃 로드 실패:", error);
toast.error("화면 레이아웃을 불러오는데 실패했습니다.");
} finally {
setIsLoading(false);
}
};
loadLayout();
}
}, [selectedScreen?.screenId]);
// 저장
const handleSave = useCallback(async () => {
if (!selectedScreen?.screenId) return;
try {
setIsSaving(true);
// 🔍 디버깅: 저장할 레이아웃 데이터 확인
console.log("🔍 레이아웃 저장 요청:", {
screenId: selectedScreen.screenId,
componentsCount: layout.components.length,
components: layout.components.map(c => ({
id: c.id,
type: c.type,
webTypeConfig: (c as any).webTypeConfig,
})),
});
const response = await screenApi.saveScreenLayout(selectedScreen.screenId, layout);
if (response.success) {
toast.success("화면이 저장되었습니다.");
setHasUnsavedChanges(false);
} else {
toast.error("저장에 실패했습니다.");
}
} catch (error) {
// console.error("저장 실패:", error);
toast.error("저장 중 오류가 발생했습니다.");
} finally {
setIsSaving(false);
}
}, [selectedScreen?.screenId, layout]);
// 드래그 앤 드롭 처리
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
}, []);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
const dragData = e.dataTransfer.getData("application/json");
if (!dragData) return;
try {
const { type, table, column } = JSON.parse(dragData);
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
let newComponent: ComponentData;
if (type === "table") {
// 테이블 컨테이너 생성
newComponent = {
id: generateComponentId(),
type: "container",
label: table.tableLabel || table.tableName, // 테이블 라벨 우선, 없으면 테이블명
tableName: table.tableName,
position: { x, y, z: 1 },
size: { width: 300, height: 200 },
};
} else if (type === "column") {
// 컬럼 위젯 생성
newComponent = {
id: generateComponentId(),
type: "widget",
label: column.columnLabel || column.columnName, // 컬럼 라벨 우선, 없으면 컬럼명
tableName: table.tableName,
columnName: column.columnName,
widgetType: column.widgetType,
dataType: column.dataType,
required: column.required,
position: { x, y, z: 1 },
size: { width: 200, height: 40 },
};
} else {
return;
}
// 격자 스냅 적용
if (layout.gridSettings?.snapToGrid && gridInfo) {
newComponent.position = snapToGrid(newComponent.position, gridInfo, layout.gridSettings as GridUtilSettings);
newComponent.size = snapSizeToGrid(newComponent.size, gridInfo, layout.gridSettings as GridUtilSettings);
}
const newLayout = {
...layout,
components: [...layout.components, newComponent],
};
setLayout(newLayout);
saveToHistory(newLayout);
setSelectedComponent(newComponent);
// 속성 패널 자동 열기
openPanel("properties");
} catch (error) {
// console.error("드롭 처리 실패:", error);
}
},
[layout, gridInfo, saveToHistory, openPanel],
);
// 컴포넌트 클릭 처리
const handleComponentClick = useCallback(
(component: ComponentData, event?: React.MouseEvent) => {
event?.stopPropagation();
setSelectedComponent(component);
// 속성 패널 자동 열기
openPanel("properties");
},
[openPanel],
);
// 컴포넌트 삭제
const deleteComponent = useCallback(() => {
if (!selectedComponent) return;
const newComponents = layout.components.filter((comp) => comp.id !== selectedComponent.id);
const newLayout = { ...layout, components: newComponents };
setLayout(newLayout);
saveToHistory(newLayout);
setSelectedComponent(null);
}, [selectedComponent, layout, saveToHistory]);
// 컴포넌트 복사
const copyComponent = useCallback(() => {
if (!selectedComponent) return;
setClipboard({
type: "single",
data: [{ ...selectedComponent, id: generateComponentId() }],
});
toast.success("컴포넌트가 복사되었습니다.");
}, [selectedComponent]);
// 그룹 생성
const handleGroupCreate = useCallback(
(componentIds: string[], title: string, style?: any) => {
const selectedComponents = layout.components.filter((comp) => componentIds.includes(comp.id));
if (selectedComponents.length < 2) return;
// 경계 박스 계산
const boundingBox = calculateBoundingBox(selectedComponents);
// 그룹 컴포넌트 생성
const groupComponent = createGroupComponent(
componentIds,
title,
{ x: boundingBox.minX, y: boundingBox.minY },
{ width: boundingBox.width, height: boundingBox.height },
style,
);
// 자식 컴포넌트들의 상대 위치 계산
const relativeChildren = calculateRelativePositions(
selectedComponents,
{ x: boundingBox.minX, y: boundingBox.minY },
groupComponent.id,
);
const newLayout = {
...layout,
components: [
...layout.components.filter((comp) => !componentIds.includes(comp.id)),
groupComponent,
...relativeChildren,
],
};
setLayout(newLayout);
saveToHistory(newLayout);
setGroupState((prev) => ({ ...prev, selectedComponents: [] }));
},
[layout, saveToHistory],
);
// 키보드 이벤트 처리
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Delete 키로 컴포넌트 삭제
if (e.key === "Delete" && selectedComponent) {
deleteComponent();
}
// Ctrl+C로 복사
if (e.ctrlKey && e.key === "c" && selectedComponent) {
copyComponent();
}
// Ctrl+Z로 실행취소
if (e.ctrlKey && e.key === "z" && !e.shiftKey) {
e.preventDefault();
undo();
}
// Ctrl+Y 또는 Ctrl+Shift+Z로 다시실행
if ((e.ctrlKey && e.key === "y") || (e.ctrlKey && e.shiftKey && e.key === "z")) {
e.preventDefault();
redo();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [selectedComponent, deleteComponent, copyComponent, undo, redo]);
if (!selectedScreen) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<Database className="mx-auto mb-4 h-12 w-12 text-gray-400" />
<h3 className="text-lg font-medium text-gray-900"> </h3>
<p className="text-gray-500"> .</p>
</div>
</div>
);
}
return (
<div className="flex h-screen w-full flex-col bg-gray-100">
{/* 상단 툴바 */}
<DesignerToolbar
screenName={selectedScreen?.screenName}
tableName={selectedScreen?.tableName}
onBack={onBackToList}
onSave={handleSave}
onUndo={undo}
onRedo={redo}
onPreview={() => {
toast.info("미리보기 기능은 준비 중입니다.");
}}
onTogglePanel={togglePanel}
panelStates={panelStates}
canUndo={historyIndex > 0}
canRedo={historyIndex < history.length - 1}
isSaving={isSaving}
/>
{/* 메인 캔버스 영역 (전체 화면) */}
<div
ref={canvasRef}
className="relative flex-1 overflow-hidden bg-white"
onClick={(e) => {
if (e.target === e.currentTarget) {
setSelectedComponent(null);
setGroupState((prev) => ({ ...prev, selectedComponents: [] }));
}
}}
onDrop={handleDrop}
onDragOver={handleDragOver}
>
{/* 격자 라인 */}
{gridLines.map((line, index) => (
<div
key={index}
className="pointer-events-none absolute"
style={{
left: line.type === "vertical" ? `${line.position}px` : 0,
top: line.type === "horizontal" ? `${line.position}px` : 0,
width: line.type === "vertical" ? "1px" : "100%",
height: line.type === "horizontal" ? "1px" : "100%",
backgroundColor: layout.gridSettings?.gridColor || "#e5e7eb",
opacity: layout.gridSettings?.gridOpacity || 0.3,
}}
/>
))}
{/* 컴포넌트들 */}
{layout.components
.filter((component) => !component.parentId) // 최상위 컴포넌트만 렌더링
.map((component) => {
const children =
component.type === "group" ? layout.components.filter((child) => child.parentId === component.id) : [];
return (
<RealtimePreview
key={component.id}
component={component}
isSelected={selectedComponent?.id === component.id}
onClick={(e) => handleComponentClick(component, e)}
>
{children.map((child) => (
<RealtimePreview
key={child.id}
component={child}
isSelected={selectedComponent?.id === child.id}
onClick={(e) => handleComponentClick(child, e)}
/>
))}
</RealtimePreview>
);
})}
{/* 빈 캔버스 안내 */}
{layout.components.length === 0 && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="text-center text-gray-400">
<Database className="mx-auto mb-4 h-16 w-16" />
<h3 className="mb-2 text-xl font-medium"> </h3>
<p className="text-sm"> </p>
<p className="mt-2 text-xs">단축키: T(), P(), S(), G()</p>
</div>
</div>
)}
</div>
{/* 플로팅 패널들 */}
<FloatingPanel
id="tables"
title="테이블 목록"
isOpen={panelStates.tables?.isOpen || false}
onClose={() => closePanel("tables")}
position="left"
width={320}
height={600}
>
<TablesPanel
tables={filteredTables}
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
onDragStart={(e, table, column) => {
const dragData = {
type: column ? "column" : "table",
table,
column,
};
e.dataTransfer.setData("application/json", JSON.stringify(dragData));
}}
selectedTableName={selectedScreen.tableName}
/>
</FloatingPanel>
<FloatingPanel
id="properties"
title="속성 편집"
isOpen={panelStates.properties?.isOpen || false}
onClose={() => closePanel("properties")}
position="right"
width={320}
height={500}
>
<PropertiesPanel
selectedComponent={selectedComponent}
onUpdateProperty={updateComponentProperty}
onDeleteComponent={deleteComponent}
onCopyComponent={copyComponent}
/>
</FloatingPanel>
<FloatingPanel
id="styles"
title="스타일 편집"
isOpen={panelStates.styles?.isOpen || false}
onClose={() => closePanel("styles")}
position="right"
width={320}
height={400}
>
{selectedComponent ? (
<div className="p-4">
<StyleEditor
style={selectedComponent.style || {}}
onStyleChange={(newStyle) => updateComponentProperty(selectedComponent.id, "style", newStyle)}
/>
</div>
) : (
<div className="flex h-full items-center justify-center text-gray-500">
</div>
)}
</FloatingPanel>
<FloatingPanel
id="grid"
title="격자 설정"
isOpen={panelStates.grid?.isOpen || false}
onClose={() => closePanel("grid")}
position="right"
width={280}
height={450}
>
<GridPanel
gridSettings={layout.gridSettings || { columns: 12, gap: 16, padding: 16, snapToGrid: true, showGrid: true }}
onGridSettingsChange={(settings) => {
const newLayout = { ...layout, gridSettings: settings };
setLayout(newLayout);
saveToHistory(newLayout);
}}
onResetGrid={() => {
const defaultSettings = { columns: 12, gap: 16, padding: 16, snapToGrid: true, showGrid: true };
const newLayout = { ...layout, gridSettings: defaultSettings };
setLayout(newLayout);
saveToHistory(newLayout);
}}
/>
</FloatingPanel>
{/* 그룹 생성 툴바 (필요시) */}
{groupState.selectedComponents.length > 1 && (
<div className="fixed bottom-4 left-1/2 z-50 -translate-x-1/2 transform">
<GroupingToolbar
selectedComponents={groupState.selectedComponents}
onGroupCreate={handleGroupCreate}
showCreateDialog={showGroupCreateDialog}
onShowCreateDialogChange={setShowGroupCreateDialog}
/>
</div>
)}
</div>
);
}