ERP-node/frontend/components/pop/designer/PopDesigner.tsx

407 lines
13 KiB
TypeScript
Raw Normal View History

"use client";
import { useState, useCallback, useEffect } from "react";
import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
import { ArrowLeft, Save, Undo2, Redo2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import { toast } from "sonner";
import PopCanvas from "./PopCanvas";
import ComponentEditorPanel from "./panels/ComponentEditorPanel";
import ComponentPalette from "./panels/ComponentPalette";
import {
PopLayoutDataV5,
PopComponentType,
PopComponentDefinitionV5,
PopGridPosition,
GridMode,
createEmptyPopLayoutV5,
isV5Layout,
addComponentToV5Layout,
} from "./types/pop-layout";
import { screenApi } from "@/lib/api/screen";
import { ScreenDefinition } from "@/types/screen";
// ========================================
// Props
// ========================================
interface PopDesignerProps {
selectedScreen: ScreenDefinition;
onBackToList: () => void;
onScreenUpdate?: (updatedScreen: Partial<ScreenDefinition>) => void;
}
// ========================================
// 메인 컴포넌트 (v5 그리드 시스템 전용)
// ========================================
export default function PopDesigner({
selectedScreen,
onBackToList,
onScreenUpdate,
}: PopDesignerProps) {
// ========================================
// 레이아웃 상태
// ========================================
const [layout, setLayout] = useState<PopLayoutDataV5>(createEmptyPopLayoutV5());
// 히스토리
const [history, setHistory] = useState<PopLayoutDataV5[]>([]);
const [historyIndex, setHistoryIndex] = useState(-1);
// UI 상태
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
const [idCounter, setIdCounter] = useState(1);
// 선택 상태
const [selectedComponentId, setSelectedComponentId] = useState<string | null>(null);
// 그리드 모드 (4개 프리셋)
const [currentMode, setCurrentMode] = useState<GridMode>("tablet_landscape");
// 선택된 컴포넌트
const selectedComponent: PopComponentDefinitionV5 | null = selectedComponentId
? layout.components[selectedComponentId] || null
: null;
// ========================================
// 히스토리 관리
// ========================================
const saveToHistory = useCallback((newLayout: PopLayoutDataV5) => {
setHistory((prev) => {
const newHistory = prev.slice(0, historyIndex + 1);
newHistory.push(JSON.parse(JSON.stringify(newLayout)));
// 최대 50개 유지
if (newHistory.length > 50) {
newHistory.shift();
return newHistory;
}
return newHistory;
});
setHistoryIndex((prev) => Math.min(prev + 1, 49));
}, [historyIndex]);
const undo = useCallback(() => {
if (historyIndex > 0) {
const newIndex = historyIndex - 1;
const previousLayout = history[newIndex];
if (previousLayout) {
setLayout(JSON.parse(JSON.stringify(previousLayout)));
setHistoryIndex(newIndex);
setHasChanges(true);
toast.success("실행 취소됨");
}
}
}, [historyIndex, history]);
const redo = useCallback(() => {
if (historyIndex < history.length - 1) {
const newIndex = historyIndex + 1;
const nextLayout = history[newIndex];
if (nextLayout) {
setLayout(JSON.parse(JSON.stringify(nextLayout)));
setHistoryIndex(newIndex);
setHasChanges(true);
toast.success("다시 실행됨");
}
}
}, [historyIndex, history]);
const canUndo = historyIndex > 0;
const canRedo = historyIndex < history.length - 1;
// ========================================
// 레이아웃 로드
// ========================================
useEffect(() => {
const loadLayout = async () => {
if (!selectedScreen?.screenId) return;
setIsLoading(true);
try {
const loadedLayout = await screenApi.getLayoutPop(selectedScreen.screenId);
if (loadedLayout && isV5Layout(loadedLayout) && Object.keys(loadedLayout.components).length > 0) {
// v5 레이아웃 로드
setLayout(loadedLayout);
setHistory([loadedLayout]);
setHistoryIndex(0);
console.log(`POP 레이아웃 로드: ${Object.keys(loadedLayout.components).length}개 컴포넌트`);
} else {
// 새 화면 또는 빈 레이아웃
const emptyLayout = createEmptyPopLayoutV5();
setLayout(emptyLayout);
setHistory([emptyLayout]);
setHistoryIndex(0);
console.log("새 POP 화면 생성 (v5 그리드)");
}
} catch (error) {
console.error("레이아웃 로드 실패:", error);
toast.error("레이아웃을 불러오는데 실패했습니다");
const emptyLayout = createEmptyPopLayoutV5();
setLayout(emptyLayout);
setHistory([emptyLayout]);
setHistoryIndex(0);
} finally {
setIsLoading(false);
}
};
loadLayout();
}, [selectedScreen?.screenId]);
// ========================================
// 저장
// ========================================
const handleSave = useCallback(async () => {
if (!selectedScreen?.screenId) return;
setIsSaving(true);
try {
await screenApi.saveLayoutPop(selectedScreen.screenId, layout);
toast.success("저장되었습니다");
setHasChanges(false);
} catch (error) {
console.error("저장 실패:", error);
toast.error("저장에 실패했습니다");
} finally {
setIsSaving(false);
}
}, [selectedScreen?.screenId, layout]);
// ========================================
// 컴포넌트 핸들러
// ========================================
const handleDropComponent = useCallback(
(type: PopComponentType, position: PopGridPosition) => {
const componentId = `comp_${idCounter}`;
setIdCounter((prev) => prev + 1);
const newLayout = addComponentToV5Layout(layout, componentId, type, position, `${type} ${idCounter}`);
setLayout(newLayout);
saveToHistory(newLayout);
setSelectedComponentId(componentId);
setHasChanges(true);
},
[idCounter, layout, saveToHistory]
);
const handleUpdateComponent = useCallback(
(componentId: string, updates: Partial<PopComponentDefinitionV5>) => {
const existingComponent = layout.components[componentId];
if (!existingComponent) return;
const newLayout = {
...layout,
components: {
...layout.components,
[componentId]: {
...existingComponent,
...updates,
},
},
};
setLayout(newLayout);
saveToHistory(newLayout);
setHasChanges(true);
},
[layout, saveToHistory]
);
const handleDeleteComponent = useCallback(
(componentId: string) => {
const newComponents = { ...layout.components };
delete newComponents[componentId];
const newLayout = {
...layout,
components: newComponents,
};
setLayout(newLayout);
saveToHistory(newLayout);
setSelectedComponentId(null);
setHasChanges(true);
},
[layout, saveToHistory]
);
// ========================================
// 뒤로가기
// ========================================
const handleBack = useCallback(() => {
if (hasChanges) {
if (confirm("저장하지 않은 변경사항이 있습니다. 정말 나가시겠습니까?")) {
onBackToList();
}
} else {
onBackToList();
}
}, [hasChanges, onBackToList]);
// ========================================
// 단축키 처리
// ========================================
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable) {
return;
}
const key = e.key.toLowerCase();
const isCtrlOrCmd = e.ctrlKey || e.metaKey;
// Delete / Backspace: 컴포넌트 삭제
if (e.key === "Delete" || e.key === "Backspace") {
e.preventDefault();
if (selectedComponentId) {
handleDeleteComponent(selectedComponentId);
}
}
// Ctrl+Z: Undo
if (isCtrlOrCmd && key === "z" && !e.shiftKey) {
e.preventDefault();
if (canUndo) undo();
return;
}
// Ctrl+Shift+Z or Ctrl+Y: Redo
if ((isCtrlOrCmd && key === "z" && e.shiftKey) || (isCtrlOrCmd && key === "y")) {
e.preventDefault();
if (canRedo) redo();
return;
}
// Ctrl+S: 저장
if (isCtrlOrCmd && key === "s") {
e.preventDefault();
handleSave();
return;
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [selectedComponentId, handleDeleteComponent, canUndo, canRedo, undo, redo, handleSave]);
// ========================================
// 로딩
// ========================================
if (isLoading) {
return (
<div className="flex h-screen items-center justify-center">
<div className="text-muted-foreground"> ...</div>
</div>
);
}
// ========================================
// 렌더링
// ========================================
return (
<DndProvider backend={HTML5Backend}>
<div className="flex h-screen flex-col">
{/* 헤더 */}
<div className="flex items-center justify-between border-b bg-white px-4 py-2">
{/* 왼쪽: 뒤로가기 + 화면명 */}
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="icon"
onClick={handleBack}
className="h-8 w-8"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h2 className="text-sm font-medium">{selectedScreen?.screenName}</h2>
<p className="text-xs text-muted-foreground">
(v5)
</p>
</div>
</div>
{/* 오른쪽: Undo/Redo + 저장 */}
<div className="flex items-center gap-2">
{/* Undo/Redo 버튼 */}
<div className="flex items-center gap-1 border-r pr-2 mr-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={undo}
disabled={!canUndo}
title="실행 취소 (Ctrl+Z)"
>
<Undo2 className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={redo}
disabled={!canRedo}
title="다시 실행 (Ctrl+Shift+Z)"
>
<Redo2 className="h-4 w-4" />
</Button>
</div>
{/* 저장 버튼 */}
<Button size="sm" onClick={handleSave} disabled={isSaving || !hasChanges}>
<Save className="mr-1 h-4 w-4" />
{isSaving ? "저장 중..." : "저장"}
</Button>
</div>
</div>
{/* 메인 영역 */}
<ResizablePanelGroup direction="horizontal" className="flex-1">
{/* 왼쪽: 컴포넌트 팔레트 */}
<ResizablePanel defaultSize={15} minSize={12} maxSize={20}>
<ComponentPalette />
</ResizablePanel>
<ResizableHandle withHandle />
{/* 중앙: 캔버스 */}
<ResizablePanel defaultSize={65}>
<PopCanvas
layout={layout}
selectedComponentId={selectedComponentId}
currentMode={currentMode}
onModeChange={setCurrentMode}
onSelectComponent={setSelectedComponentId}
onDropComponent={handleDropComponent}
onUpdateComponent={handleUpdateComponent}
onDeleteComponent={handleDeleteComponent}
/>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 오른쪽: 속성 패널 */}
<ResizablePanel defaultSize={20} minSize={15} maxSize={30}>
<ComponentEditorPanel
component={selectedComponent}
currentMode={currentMode}
onUpdateComponent={
selectedComponentId
? (updates) => handleUpdateComponent(selectedComponentId, updates)
: undefined
}
/>
</ResizablePanel>
</ResizablePanelGroup>
</div>
</DndProvider>
);
}