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

351 lines
12 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, Smartphone, Tablet } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import { toast } from "sonner";
import { PopCanvas } from "./PopCanvas";
import { PopPanel } from "./panels/PopPanel";
import {
PopLayoutDataV3,
PopLayoutModeKey,
PopComponentType,
GridPosition,
PopComponentDefinition,
createEmptyPopLayoutV3,
ensureV3Layout,
addComponentToV3Layout,
removeComponentFromV3Layout,
updateComponentPositionInModeV3,
isV3Layout,
} from "./types/pop-layout";
import { screenApi } from "@/lib/api/screen";
import { ScreenDefinition } from "@/types/screen";
// ========================================
// 디바이스 타입
// ========================================
type DeviceType = "mobile" | "tablet";
// ========================================
// Props
// ========================================
interface PopDesignerProps {
selectedScreen: ScreenDefinition;
onBackToList: () => void;
onScreenUpdate?: (updatedScreen: Partial<ScreenDefinition>) => void;
}
// ========================================
// 메인 컴포넌트 (v3: 섹션 없이 컴포넌트 직접 배치)
// ========================================
export default function PopDesigner({
selectedScreen,
onBackToList,
onScreenUpdate,
}: PopDesignerProps) {
// ========================================
// 레이아웃 상태 (v3)
// ========================================
const [layout, setLayout] = useState<PopLayoutDataV3>(createEmptyPopLayoutV3());
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
// ========================================
// 디바이스/모드 상태
// ========================================
const [activeDevice, setActiveDevice] = useState<DeviceType>("tablet");
const [activeModeKey, setActiveModeKey] = useState<PopLayoutModeKey>("tablet_landscape");
// ========================================
// 선택 상태 (v3: 섹션 없음, 컴포넌트만)
// ========================================
const [selectedComponentId, setSelectedComponentId] = useState<string | null>(null);
// 선택된 컴포넌트 정의
const selectedComponent: PopComponentDefinition | null = selectedComponentId
? layout.components[selectedComponentId] || null
: null;
// ========================================
// 레이아웃 로드
// ========================================
useEffect(() => {
const loadLayout = async () => {
if (!selectedScreen?.screenId) return;
setIsLoading(true);
try {
const loadedLayout = await screenApi.getLayoutPop(selectedScreen.screenId);
if (loadedLayout) {
// v1, v2, v3 → v3로 변환
const v3Layout = ensureV3Layout(loadedLayout);
setLayout(v3Layout);
const componentCount = Object.keys(v3Layout.components).length;
console.log(`POP v3 레이아웃 로드 성공: ${componentCount}개 컴포넌트`);
if (!isV3Layout(loadedLayout)) {
console.log("v1/v2 → v3 자동 마이그레이션 완료");
}
} else {
console.log("POP 레이아웃 없음, 빈 v3 레이아웃 생성");
setLayout(createEmptyPopLayoutV3());
}
} catch (error) {
console.error("레이아웃 로드 실패:", error);
toast.error("레이아웃을 불러오는데 실패했습니다");
setLayout(createEmptyPopLayoutV3());
} 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]);
// ========================================
// 컴포넌트 추가 (4모드 동기화)
// ========================================
const handleDropComponent = useCallback(
(type: PopComponentType, gridPosition: GridPosition) => {
const newId = `${type}-${Date.now()}`;
setLayout((prev) => addComponentToV3Layout(prev, newId, type, gridPosition));
setSelectedComponentId(newId);
setHasChanges(true);
},
[]
);
// ========================================
// 컴포넌트 정의 업데이트
// ========================================
const handleUpdateComponentDefinition = useCallback(
(componentId: string, updates: Partial<PopComponentDefinition>) => {
setLayout((prev) => ({
...prev,
components: {
...prev.components,
[componentId]: {
...prev.components[componentId],
...updates,
},
},
}));
setHasChanges(true);
},
[]
);
// ========================================
// 컴포넌트 위치 업데이트 (현재 모드만)
// ========================================
const handleUpdateComponentPosition = useCallback(
(componentId: string, position: GridPosition, modeKey?: PopLayoutModeKey) => {
const targetMode = modeKey || activeModeKey;
setLayout((prev) => updateComponentPositionInModeV3(prev, targetMode, componentId, position));
setHasChanges(true);
},
[activeModeKey]
);
// ========================================
// 컴포넌트 삭제 (4모드 동기화)
// ========================================
const handleDeleteComponent = useCallback((componentId: string) => {
setLayout((prev) => removeComponentFromV3Layout(prev, componentId));
setSelectedComponentId(null);
setHasChanges(true);
}, []);
// ========================================
// 디바이스 전환
// ========================================
const handleDeviceChange = useCallback((device: DeviceType) => {
setActiveDevice(device);
setActiveModeKey(device === "tablet" ? "tablet_landscape" : "mobile_landscape");
}, []);
// ========================================
// 모드 키 전환
// ========================================
const handleModeKeyChange = useCallback((modeKey: PopLayoutModeKey) => {
setActiveModeKey(modeKey);
}, []);
// ========================================
// 뒤로가기
// ========================================
const handleBack = useCallback(() => {
if (hasChanges) {
if (confirm("저장하지 않은 변경사항이 있습니다. 나가시겠습니까?")) {
onBackToList();
}
} else {
onBackToList();
}
}, [hasChanges, onBackToList]);
// ========================================
// Delete 키 삭제 기능
// ========================================
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
if (
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable
) {
return;
}
if (e.key === "Delete" || e.key === "Backspace") {
e.preventDefault();
if (selectedComponentId) {
handleDeleteComponent(selectedComponentId);
}
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [selectedComponentId, handleDeleteComponent]);
// ========================================
// 로딩 상태
// ========================================
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 bg-background">
{/* 툴바 */}
<div className="flex h-12 items-center justify-between border-b px-4">
{/* 왼쪽: 뒤로가기 + 화면명 */}
<div className="flex items-center gap-3">
<Button variant="ghost" size="sm" onClick={handleBack}>
<ArrowLeft className="mr-1 h-4 w-4" />
</Button>
<span className="text-sm font-medium">
{selectedScreen?.screenName || "POP 화면"}
</span>
{hasChanges && (
<span className="text-xs text-orange-500">*</span>
)}
</div>
{/* 중앙: 디바이스 전환 */}
<div className="flex items-center gap-2">
<Tabs
value={activeDevice}
onValueChange={(v) => handleDeviceChange(v as DeviceType)}
>
<TabsList className="h-8">
<TabsTrigger value="tablet" className="h-7 px-3 text-xs">
<Tablet className="mr-1 h-3.5 w-3.5" />
릿
</TabsTrigger>
<TabsTrigger value="mobile" className="h-7 px-3 text-xs">
<Smartphone className="mr-1 h-3.5 w-3.5" />
</TabsTrigger>
</TabsList>
</Tabs>
</div>
{/* 오른쪽: 저장 */}
<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={20}
minSize={15}
maxSize={30}
className="border-r"
>
<PopPanel
layout={layout}
activeModeKey={activeModeKey}
selectedComponentId={selectedComponentId}
selectedComponent={selectedComponent}
onUpdateComponentDefinition={handleUpdateComponentDefinition}
onDeleteComponent={handleDeleteComponent}
activeDevice={activeDevice}
/>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 오른쪽: 캔버스 */}
<ResizablePanel defaultSize={80}>
<PopCanvas
layout={layout}
activeDevice={activeDevice}
activeModeKey={activeModeKey}
onModeKeyChange={handleModeKeyChange}
selectedComponentId={selectedComponentId}
onSelectComponent={setSelectedComponentId}
onUpdateComponentPosition={handleUpdateComponentPosition}
onDropComponent={handleDropComponent}
onDeleteComponent={handleDeleteComponent}
/>
</ResizablePanel>
</ResizablePanelGroup>
</div>
</DndProvider>
);
}