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

455 lines
16 KiB
TypeScript
Raw Normal View History

"use client";
import { useState, useCallback, useEffect, useMemo } 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 {
PopLayoutDataV2,
PopLayoutModeKey,
PopComponentType,
GridPosition,
PopSectionDefinition,
createEmptyPopLayoutV2,
createSectionDefinition,
createComponentDefinition,
ensureV2Layout,
addSectionToV2Layout,
addComponentToV2Layout,
removeSectionFromV2Layout,
removeComponentFromV2Layout,
updateSectionPositionInMode,
updateComponentPositionInMode,
isV2Layout,
} from "./types/pop-layout";
import { screenApi } from "@/lib/api/screen";
import { ScreenDefinition } from "@/types/screen";
// ========================================
// 디바이스 타입
// ========================================
type DeviceType = "mobile" | "tablet";
/**
* +
*/
const getModeKey = (device: DeviceType, isLandscape: boolean): PopLayoutModeKey => {
if (device === "tablet") {
return isLandscape ? "tablet_landscape" : "tablet_portrait";
}
return isLandscape ? "mobile_landscape" : "mobile_portrait";
};
// ========================================
// Props
// ========================================
interface PopDesignerProps {
selectedScreen: ScreenDefinition;
onBackToList: () => void;
onScreenUpdate?: (updatedScreen: Partial<ScreenDefinition>) => void;
}
// ========================================
// 메인 컴포넌트
// ========================================
export default function PopDesigner({
selectedScreen,
onBackToList,
onScreenUpdate,
}: PopDesignerProps) {
// ========================================
// 레이아웃 상태 (v2)
// ========================================
const [layout, setLayout] = useState<PopLayoutDataV2>(createEmptyPopLayoutV2());
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");
// ========================================
// 선택 상태
// ========================================
const [selectedSectionId, setSelectedSectionId] = useState<string | null>(null);
const [selectedComponentId, setSelectedComponentId] = useState<string | null>(null);
// ========================================
// 파생 상태
// ========================================
// 선택된 섹션 정의
const selectedSection: PopSectionDefinition | null = useMemo(() => {
if (!selectedSectionId) return null;
return layout.sections[selectedSectionId] || null;
}, [layout.sections, selectedSectionId]);
// 현재 활성 모드의 섹션 ID 목록
const activeSectionIds = useMemo(() => {
return Object.keys(layout.layouts[activeModeKey].sectionPositions);
}, [layout.layouts, activeModeKey]);
// ========================================
// 레이아웃 로드
// ========================================
useEffect(() => {
const loadLayout = async () => {
if (!selectedScreen?.screenId) return;
setIsLoading(true);
try {
// API가 layout_data 내용을 직접 반환 (언래핑 상태)
const loadedLayout = await screenApi.getLayoutPop(selectedScreen.screenId);
if (loadedLayout) {
// v1 또는 v2 → v2로 변환
const v2Layout = ensureV2Layout(loadedLayout);
setLayout(v2Layout);
const sectionCount = Object.keys(v2Layout.sections).length;
const componentCount = Object.keys(v2Layout.components).length;
console.log(`POP v2 레이아웃 로드 성공: ${sectionCount}개 섹션, ${componentCount}개 컴포넌트`);
// v1에서 마이그레이션된 경우 알림
if (!isV2Layout(loadedLayout)) {
console.log("v1 → v2 자동 마이그레이션 완료");
}
} else {
// 레이아웃 없음 - 빈 v2 레이아웃 생성
console.log("POP 레이아웃 없음, 빈 v2 레이아웃 생성");
setLayout(createEmptyPopLayoutV2());
}
} catch (error) {
console.error("레이아웃 로드 실패:", error);
toast.error("레이아웃을 불러오는데 실패했습니다");
setLayout(createEmptyPopLayoutV2());
} 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 handleDropSection = useCallback((gridPosition: GridPosition) => {
const newId = `section-${Date.now()}`;
setLayout((prev) => addSectionToV2Layout(prev, newId, gridPosition));
setSelectedSectionId(newId);
setHasChanges(true);
}, []);
// ========================================
// 컴포넌트 추가 (4모드 동기화)
// ========================================
const handleDropComponent = useCallback(
(sectionId: string, type: PopComponentType, gridPosition: GridPosition) => {
const newId = `${type}-${Date.now()}`;
setLayout((prev) => addComponentToV2Layout(prev, sectionId, newId, type, gridPosition));
setSelectedComponentId(newId);
setHasChanges(true);
},
[]
);
// ========================================
// 섹션 정의 업데이트 (공유)
// ========================================
const handleUpdateSectionDefinition = useCallback(
(sectionId: string, updates: Partial<PopSectionDefinition>) => {
setLayout((prev) => ({
...prev,
sections: {
...prev.sections,
[sectionId]: {
...prev.sections[sectionId],
...updates,
},
},
}));
setHasChanges(true);
},
[]
);
// ========================================
// 섹션 위치 업데이트 (현재 모드만)
// ========================================
const handleUpdateSectionPosition = useCallback(
(sectionId: string, position: GridPosition, modeKey?: PopLayoutModeKey) => {
const targetMode = modeKey || activeModeKey;
setLayout((prev) => updateSectionPositionInMode(prev, targetMode, sectionId, position));
setHasChanges(true);
},
[activeModeKey]
);
// ========================================
// 컴포넌트 위치 업데이트 (현재 모드만)
// ========================================
const handleUpdateComponentPosition = useCallback(
(componentId: string, position: GridPosition, modeKey?: PopLayoutModeKey) => {
const targetMode = modeKey || activeModeKey;
setLayout((prev) => updateComponentPositionInMode(prev, targetMode, componentId, position));
setHasChanges(true);
},
[activeModeKey]
);
// ========================================
// 섹션 삭제 (4모드 동기화)
// ========================================
const handleDeleteSection = useCallback((sectionId: string) => {
setLayout((prev) => removeSectionFromV2Layout(prev, sectionId));
setSelectedSectionId(null);
setSelectedComponentId(null);
setHasChanges(true);
}, []);
// ========================================
// 컴포넌트 삭제 (4모드 동기화)
// ========================================
const handleDeleteComponent = useCallback(
(sectionId: string, componentId: string) => {
setLayout((prev) => removeComponentFromV2Layout(prev, sectionId, 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) => {
// input/textarea 포커스 시 제외
const target = e.target as HTMLElement;
if (
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable
) {
return;
}
// Delete 또는 Backspace 키
if (e.key === "Delete" || e.key === "Backspace") {
e.preventDefault(); // 브라우저 뒤로가기 방지
// 컴포넌트가 선택되어 있으면 컴포넌트 삭제
if (selectedComponentId) {
// v2 구조: 컴포넌트가 속한 섹션을 sections의 componentIds에서 찾기
// (PopComponentDefinition에는 sectionId가 없으므로 섹션을 순회하여 찾음)
let foundSectionId: string | null = null;
for (const [sectionId, sectionDef] of Object.entries(layout.sections)) {
if (sectionDef.componentIds.includes(selectedComponentId)) {
foundSectionId = sectionId;
break;
}
}
if (foundSectionId) {
handleDeleteComponent(foundSectionId, selectedComponentId);
}
}
// 컴포넌트가 선택되지 않았고 섹션이 선택되어 있으면 섹션 삭제
else if (selectedSectionId) {
handleDeleteSection(selectedSectionId);
}
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [
selectedComponentId,
selectedSectionId,
layout.sections,
handleDeleteComponent,
handleDeleteSection,
]);
// ========================================
// 로딩 상태
// ========================================
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>
{/* 중앙: 디바이스 전환 (가로/세로 전환 버튼 제거 - 캔버스 2개 동시 표시) */}
<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}
selectedSectionId={selectedSectionId}
selectedSection={selectedSection}
onUpdateSectionDefinition={handleUpdateSectionDefinition}
onDeleteSection={handleDeleteSection}
activeDevice={activeDevice}
/>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 오른쪽: 캔버스 (가로+세로 2개 동시 표시) */}
<ResizablePanel defaultSize={80}>
<PopCanvas
layout={layout}
activeDevice={activeDevice}
activeModeKey={activeModeKey}
onModeKeyChange={handleModeKeyChange}
selectedSectionId={selectedSectionId}
selectedComponentId={selectedComponentId}
onSelectSection={setSelectedSectionId}
onSelectComponent={setSelectedComponentId}
onUpdateSectionPosition={handleUpdateSectionPosition}
onUpdateComponentPosition={handleUpdateComponentPosition}
onDeleteSection={handleDeleteSection}
onDropSection={handleDropSection}
onDropComponent={handleDropComponent}
onDeleteComponent={handleDeleteComponent}
/>
</ResizablePanel>
</ResizablePanelGroup>
</div>
</DndProvider>
);
}