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

360 lines
11 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, Columns2, RotateCcw } 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 {
PopLayoutData,
PopSectionData,
PopComponentData,
PopComponentType,
createEmptyPopLayout,
createPopSection,
createPopComponent,
GridPosition,
} from "./types/pop-layout";
import { screenApi } from "@/lib/api/screen";
import { ScreenDefinition } from "@/types/screen";
// 디바이스 타입
type DeviceType = "mobile" | "tablet";
interface PopDesignerProps {
selectedScreen: ScreenDefinition;
onBackToList: () => void;
onScreenUpdate?: (updatedScreen: Partial<ScreenDefinition>) => void;
}
export default function PopDesigner({
selectedScreen,
onBackToList,
onScreenUpdate,
}: PopDesignerProps) {
// 레이아웃 상태
const [layout, setLayout] = useState<PopLayoutData>(createEmptyPopLayout());
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
// 디바이스 프리뷰 상태
const [activeDevice, setActiveDevice] = useState<DeviceType>("tablet");
const [showBothDevices, setShowBothDevices] = useState(false);
const [isLandscape, setIsLandscape] = useState(true);
// 선택된 섹션/컴포넌트
const [selectedSectionId, setSelectedSectionId] = useState<string | null>(null);
const [selectedComponentId, setSelectedComponentId] = useState<string | null>(null);
// 선택된 섹션 객체
const selectedSection = selectedSectionId
? layout.sections.find((s) => s.id === selectedSectionId) || null
: null;
// 레이아웃 로드
// API는 이미 언래핑된 layout_data를 반환하므로 response 자체가 레이아웃 데이터
useEffect(() => {
const loadLayout = async () => {
if (!selectedScreen?.screenId) return;
setIsLoading(true);
try {
// API가 layout_data 내용을 직접 반환함 (언래핑된 상태)
const loadedLayout = await screenApi.getLayoutPop(selectedScreen.screenId);
if (loadedLayout && loadedLayout.version === "pop-1.0") {
// 유효한 POP 레이아웃
setLayout(loadedLayout as PopLayoutData);
console.log("POP 레이아웃 로드 성공:", loadedLayout.sections?.length || 0, "개 섹션");
} else if (loadedLayout && loadedLayout.sections) {
// 버전 태그 없지만 sections 구조가 있으면 사용
console.warn("버전 태그 없음, sections 구조 감지하여 사용");
setLayout({
...createEmptyPopLayout(),
...loadedLayout,
version: "pop-1.0",
} as PopLayoutData);
} else {
// 레이아웃 없음 - 빈 레이아웃 생성
console.log("POP 레이아웃 없음, 빈 레이아웃 생성");
setLayout(createEmptyPopLayout());
}
} catch (error) {
console.error("레이아웃 로드 실패:", error);
toast.error("레이아웃을 불러오는데 실패했습니다");
setLayout(createEmptyPopLayout());
} 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 handleDropSection = useCallback((gridPosition: GridPosition) => {
const newId = `section-${Date.now()}`;
const newSection = createPopSection(newId, gridPosition);
setLayout((prev) => ({
...prev,
sections: [...prev.sections, newSection],
}));
setSelectedSectionId(newId);
setHasChanges(true);
}, []);
// 컴포넌트 드롭 (팔레트 → 섹션)
const handleDropComponent = useCallback(
(sectionId: string, type: PopComponentType, gridPosition: GridPosition) => {
const newId = `${type}-${Date.now()}`;
const newComponent = createPopComponent(newId, type, gridPosition);
setLayout((prev) => ({
...prev,
sections: prev.sections.map((s) =>
s.id === sectionId
? { ...s, components: [...s.components, newComponent] }
: s
),
}));
setSelectedComponentId(newId);
setHasChanges(true);
},
[]
);
// 섹션 업데이트
const handleUpdateSection = useCallback(
(id: string, updates: Partial<PopSectionData>) => {
setLayout((prev) => ({
...prev,
sections: prev.sections.map((s) =>
s.id === id ? { ...s, ...updates } : s
),
}));
setHasChanges(true);
},
[]
);
// 섹션 삭제
const handleDeleteSection = useCallback((id: string) => {
setLayout((prev) => ({
...prev,
sections: prev.sections.filter((s) => s.id !== id),
}));
setSelectedSectionId(null);
setHasChanges(true);
}, []);
// 레이아웃 변경 (드래그/리사이즈)
const handleLayoutChange = useCallback((sections: PopSectionData[]) => {
setLayout((prev) => ({
...prev,
sections,
}));
setHasChanges(true);
}, []);
// 컴포넌트 업데이트
const handleUpdateComponent = useCallback(
(sectionId: string, componentId: string, updates: Partial<PopComponentData>) => {
setLayout((prev) => ({
...prev,
sections: prev.sections.map((s) =>
s.id === sectionId
? {
...s,
components: s.components.map((c) =>
c.id === componentId ? { ...c, ...updates } : c
),
}
: s
),
}));
setHasChanges(true);
},
[]
);
// 컴포넌트 삭제
const handleDeleteComponent = useCallback(
(sectionId: string, componentId: string) => {
setLayout((prev) => ({
...prev,
sections: prev.sections.map((s) =>
s.id === sectionId
? { ...s, components: s.components.filter((c) => c.id !== componentId) }
: s
),
}));
setSelectedComponentId(null);
setHasChanges(true);
},
[]
);
// 뒤로가기
const handleBack = useCallback(() => {
if (hasChanges) {
if (confirm("저장하지 않은 변경사항이 있습니다. 나가시겠습니까?")) {
onBackToList();
}
} else {
onBackToList();
}
}, [hasChanges, onBackToList]);
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) => setActiveDevice(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>
<Button
variant="ghost"
size="sm"
onClick={() => setIsLandscape(!isLandscape)}
title={isLandscape ? "세로 모드로 전환" : "가로 모드로 전환"}
>
<RotateCcw className="h-4 w-4" />
</Button>
<Button
variant={showBothDevices ? "secondary" : "ghost"}
size="sm"
onClick={() => setShowBothDevices(!showBothDevices)}
title="나란히 보기"
>
<Columns2 className="h-4 w-4" />
</Button>
</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}
selectedSectionId={selectedSectionId}
selectedSection={selectedSection}
onUpdateSection={handleUpdateSection}
onDeleteSection={handleDeleteSection}
activeDevice={activeDevice}
/>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 오른쪽: 캔버스 */}
<ResizablePanel defaultSize={80}>
<PopCanvas
layout={layout}
activeDevice={activeDevice}
showBothDevices={showBothDevices}
isLandscape={isLandscape}
selectedSectionId={selectedSectionId}
selectedComponentId={selectedComponentId}
onSelectSection={setSelectedSectionId}
onSelectComponent={setSelectedComponentId}
onUpdateSection={handleUpdateSection}
onDeleteSection={handleDeleteSection}
onLayoutChange={handleLayoutChange}
onDropSection={handleDropSection}
onDropComponent={handleDropComponent}
onUpdateComponent={handleUpdateComponent}
onDeleteComponent={handleDeleteComponent}
/>
</ResizablePanel>
</ResizablePanelGroup>
</div>
</DndProvider>
);
}