diff --git a/frontend/app/(pop)/pop/screens/[screenId]/page.tsx b/frontend/app/(pop)/pop/screens/[screenId]/page.tsx index 4726189c..b09df855 100644 --- a/frontend/app/(pop)/pop/screens/[screenId]/page.tsx +++ b/frontend/app/(pop)/pop/screens/[screenId]/page.tsx @@ -20,15 +20,18 @@ import { ActiveTabProvider } from "@/contexts/ActiveTabContext"; import { ScreenMultiLangProvider } from "@/contexts/ScreenMultiLangContext"; import { PopLayoutDataV3, + PopLayoutDataV4, PopLayoutModeKey, ensureV3Layout, isV3Layout, + isV4Layout, } from "@/components/pop/designer/types/pop-layout"; import { PopLayoutRenderer, hasBaseLayout, getEffectiveModeLayout, } from "@/components/pop/designer/renderers"; +import { PopFlexRenderer } from "@/components/pop/designer/renderers/PopFlexRenderer"; import { useResponsiveMode, useResponsiveModeWithOverride, @@ -63,12 +66,18 @@ const isPopLayoutV3 = (layout: any): layout is PopLayoutDataV3 => { return layout && layout.version === "pop-3.0" && layout.layouts && layout.components; }; -// v1/v2 레이아웃인지 확인 (마이그레이션 대상) +// v4.0 레이아웃인지 확인 +const isPopLayoutV4 = (layout: any): layout is PopLayoutDataV4 => { + return layout && layout.version === "pop-4.0" && layout.root && layout.components; +}; + +// v1/v2/v3/v4 레이아웃인지 확인 const isPopLayout = (layout: any): boolean => { return layout && ( layout.version === "pop-1.0" || layout.version === "pop-2.0" || - layout.version === "pop-3.0" + layout.version === "pop-3.0" || + layout.version === "pop-4.0" ); }; @@ -101,6 +110,7 @@ function PopScreenViewPage() { const [screen, setScreen] = useState(null); const [layout, setLayout] = useState(null); const [popLayoutV3, setPopLayoutV3] = useState(null); + const [popLayoutV4, setPopLayoutV4] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -108,6 +118,19 @@ function PopScreenViewPage() { const [selectedRowsData, setSelectedRowsData] = useState([]); const [tableRefreshKey, setTableRefreshKey] = useState(0); + // 뷰포트 너비 (클라이언트 사이드에서만 계산, 최대 1366px) + const [viewportWidth, setViewportWidth] = useState(1024); // 기본값: 태블릿 가로 + + useEffect(() => { + const updateViewportWidth = () => { + setViewportWidth(Math.min(window.innerWidth, 1366)); + }; + + updateViewportWidth(); + window.addEventListener("resize", updateViewportWidth); + return () => window.removeEventListener("resize", updateViewportWidth); + }, []); + // 컴포넌트 초기화 useEffect(() => { const initComponents = async () => { @@ -133,10 +156,17 @@ function PopScreenViewPage() { try { const popLayout = await screenApi.getLayoutPop(screenId); - if (popLayout && isPopLayout(popLayout)) { + if (popLayout && isPopLayoutV4(popLayout)) { + // v4 레이아웃 + setPopLayoutV4(popLayout); + setPopLayoutV3(null); + const componentCount = Object.keys(popLayout.components).length; + console.log(`[POP] v4 레이아웃 로드됨: ${componentCount}개 컴포넌트`); + } else if (popLayout && isPopLayout(popLayout)) { // v1/v2/v3 → v3로 변환 const v3Layout = ensureV3Layout(popLayout); setPopLayoutV3(v3Layout); + setPopLayoutV4(null); const componentCount = Object.keys(v3Layout.components).length; console.log(`[POP] v3 레이아웃 로드됨: ${componentCount}개 컴포넌트`); @@ -151,11 +181,13 @@ function PopScreenViewPage() { } else { console.log("[POP] 레이아웃 없음"); setPopLayoutV3(null); + setPopLayoutV4(null); setLayout(null); } } catch (layoutError) { console.warn("[POP] 레이아웃 로드 실패:", layoutError); setPopLayoutV3(null); + setPopLayoutV4(null); setLayout(null); } } catch (error) { @@ -304,8 +336,20 @@ function PopScreenViewPage() { flexShrink: 0, } : undefined} > - {/* POP 레이아웃 v3.0 렌더링 */} - {popLayoutV3 ? ( + {/* POP 레이아웃 v4.0 렌더링 */} + {popLayoutV4 ? ( +
+ +
+ ) : popLayoutV3 ? ( + /* POP 레이아웃 v3.0 렌더링 */ (() => { + // 초기 테스트 데이터 + const initial = createEmptyPopLayoutV4(); + return initial; + }); + + // 선택 상태 + const [selectedComponentId, setSelectedComponentId] = useState(null); + const [selectedContainerId, setSelectedContainerId] = useState(null); + + // 컴포넌트 ID 카운터 + const [idCounter, setIdCounter] = useState(1); + + // 선택된 컴포넌트/컨테이너 가져오기 + const selectedComponent = selectedComponentId + ? layout.components[selectedComponentId] + : null; + const selectedContainer = selectedContainerId + ? findContainerV4(layout.root, selectedContainerId) + : null; + + // 컴포넌트 드롭 + const handleDropComponent = useCallback( + (type: PopComponentType, containerId: string) => { + const componentId = `comp_${idCounter}`; + setIdCounter((prev) => prev + 1); + + setLayout((prev) => + addComponentToV4Layout(prev, componentId, type, containerId, `${type} ${idCounter}`) + ); + setSelectedComponentId(componentId); + setSelectedContainerId(null); + }, + [idCounter] + ); + + // 컴포넌트 삭제 + const handleDeleteComponent = useCallback((componentId: string) => { + setLayout((prev) => removeComponentFromV4Layout(prev, componentId)); + setSelectedComponentId(null); + }, []); + + // 컴포넌트 업데이트 + const handleUpdateComponent = useCallback( + (componentId: string, updates: Partial) => { + setLayout((prev) => updateComponentInV4Layout(prev, componentId, updates)); + }, + [] + ); + + // 컨테이너 업데이트 + const handleUpdateContainer = useCallback( + (containerId: string, updates: Partial) => { + setLayout((prev) => ({ + ...prev, + root: updateContainerV4(prev.root, containerId, updates), + })); + }, + [] + ); + + // 선택 + const handleSelectComponent = useCallback((id: string | null) => { + setSelectedComponentId(id); + if (id) setSelectedContainerId(null); + }, []); + + const handleSelectContainer = useCallback((id: string | null) => { + setSelectedContainerId(id); + if (id) setSelectedComponentId(null); + }, []); + + return ( + +
+ {/* 왼쪽: 컴포넌트 팔레트 */} +
+
+

v4 테스트

+

컴포넌트를 드래그하세요

+
+ +
+ + {/* 중앙: 캔버스 */} +
+ +
+ + {/* 오른쪽: 속성 패널 */} +
+ handleUpdateComponent(selectedComponentId, updates) + : undefined + } + onUpdateContainer={ + selectedContainerId + ? (updates) => handleUpdateContainer(selectedContainerId, updates) + : undefined + } + /> +
+
+
+ ); +} diff --git a/frontend/components/pop/designer/PopCanvasV4.tsx b/frontend/components/pop/designer/PopCanvasV4.tsx new file mode 100644 index 00000000..396cf7ad --- /dev/null +++ b/frontend/components/pop/designer/PopCanvasV4.tsx @@ -0,0 +1,334 @@ +"use client"; + +import { useCallback, useRef, useState, useEffect } from "react"; +import { useDrop } from "react-dnd"; +import { cn } from "@/lib/utils"; +import { + PopLayoutDataV4, + PopContainerV4, + PopComponentDefinitionV4, + PopComponentType, + PopSizeConstraintV4, +} from "./types/pop-layout"; +import { DND_ITEM_TYPES, DragItemComponent } from "./panels/PopPanel"; +import { ZoomIn, ZoomOut, Maximize2, Smartphone, Tablet, RotateCcw, RotateCw } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { PopFlexRenderer } from "./renderers/PopFlexRenderer"; + +// ======================================== +// 프리셋 해상도 (4개 모드) +// ======================================== +const VIEWPORT_PRESETS = [ + { id: "mobile_portrait", label: "모바일 세로", shortLabel: "모바일↕", width: 375, height: 667, icon: Smartphone, isLandscape: false }, + { id: "mobile_landscape", label: "모바일 가로", shortLabel: "모바일↔", width: 667, height: 375, icon: Smartphone, isLandscape: true }, + { id: "tablet_portrait", label: "태블릿 세로", shortLabel: "태블릿↕", width: 768, height: 1024, icon: Tablet, isLandscape: false }, + { id: "tablet_landscape", label: "태블릿 가로", shortLabel: "태블릿↔", width: 1024, height: 768, icon: Tablet, isLandscape: true }, +] as const; + +type ViewportPreset = (typeof VIEWPORT_PRESETS)[number]["id"]; + +// 기본 프리셋 (태블릿 가로) +const DEFAULT_PRESET: ViewportPreset = "tablet_landscape"; + +// ======================================== +// Props +// ======================================== +interface PopCanvasV4Props { + layout: PopLayoutDataV4; + selectedComponentId: string | null; + selectedContainerId: string | null; + onSelectComponent: (id: string | null) => void; + onSelectContainer: (id: string | null) => void; + onDropComponent: (type: PopComponentType, containerId: string) => void; + onUpdateComponent: (componentId: string, updates: Partial) => void; + onUpdateContainer: (containerId: string, updates: Partial) => void; + onDeleteComponent: (componentId: string) => void; + onResizeComponent?: (componentId: string, size: Partial) => void; + onReorderComponent?: (containerId: string, fromIndex: number, toIndex: number) => void; +} + +// ======================================== +// v4 캔버스 +// +// 핵심: 단일 캔버스 + 뷰포트 프리뷰 +// - 가로/세로 모드 따로 없음 +// - 다양한 뷰포트 크기로 미리보기 +// ======================================== +export function PopCanvasV4({ + layout, + selectedComponentId, + selectedContainerId, + onSelectComponent, + onSelectContainer, + onDropComponent, + onUpdateComponent, + onUpdateContainer, + onDeleteComponent, + onResizeComponent, + onReorderComponent, +}: PopCanvasV4Props) { + // 줌 상태 + const [canvasScale, setCanvasScale] = useState(0.8); + + // 현재 뷰포트 프리셋 (기본: 태블릿 가로) + const [activeViewport, setActiveViewport] = useState(DEFAULT_PRESET); + + // 커스텀 뷰포트 크기 (슬라이더) + const [customWidth, setCustomWidth] = useState(1024); + const [customHeight, setCustomHeight] = useState(768); + + // 패닝 상태 + const [isPanning, setIsPanning] = useState(false); + const [panStart, setPanStart] = useState({ x: 0, y: 0 }); + const [isSpacePressed, setIsSpacePressed] = useState(false); + const containerRef = useRef(null); + const dropRef = useRef(null); + + // 현재 뷰포트 해상도 + const currentPreset = VIEWPORT_PRESETS.find((p) => p.id === activeViewport)!; + const viewportWidth = customWidth; + const viewportHeight = customHeight; + + // 줌 컨트롤 + const handleZoomIn = () => setCanvasScale((prev) => Math.min(1.5, prev + 0.1)); + const handleZoomOut = () => setCanvasScale((prev) => Math.max(0.3, prev - 0.1)); + const handleZoomFit = () => setCanvasScale(1.0); + + // 뷰포트 프리셋 변경 + const handleViewportChange = (preset: ViewportPreset) => { + setActiveViewport(preset); + const presetData = VIEWPORT_PRESETS.find((p) => p.id === preset)!; + setCustomWidth(presetData.width); + setCustomHeight(presetData.height); + }; + + // 슬라이더로 너비 변경 시 높이도 비율에 맞게 조정 + const handleWidthChange = (newWidth: number) => { + setCustomWidth(newWidth); + // 현재 프리셋의 가로세로 비율 유지 + const ratio = currentPreset.height / currentPreset.width; + setCustomHeight(Math.round(newWidth * ratio)); + }; + + // 패닝 + const handlePanStart = (e: React.MouseEvent) => { + const isMiddleButton = e.button === 1; + const isScrollAreaClick = (e.target as HTMLElement).classList.contains("canvas-scroll-area"); + if (isMiddleButton || isSpacePressed || isScrollAreaClick) { + setIsPanning(true); + setPanStart({ x: e.clientX, y: e.clientY }); + e.preventDefault(); + } + }; + + const handlePanMove = (e: React.MouseEvent) => { + if (!isPanning || !containerRef.current) return; + const deltaX = e.clientX - panStart.x; + const deltaY = e.clientY - panStart.y; + containerRef.current.scrollLeft -= deltaX; + containerRef.current.scrollTop -= deltaY; + setPanStart({ x: e.clientX, y: e.clientY }); + }; + + const handlePanEnd = () => setIsPanning(false); + + // 마우스 휠 줌 + const handleWheel = useCallback((e: React.WheelEvent) => { + if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + const delta = e.deltaY > 0 ? -0.1 : 0.1; + setCanvasScale((prev) => Math.max(0.3, Math.min(1.5, prev + delta))); + } + }, []); + + // Space 키 감지 (패닝용) + // 참고: Delete/Backspace 키는 PopDesigner에서 처리 (히스토리 지원) + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.code === "Space" && !isSpacePressed) setIsSpacePressed(true); + }; + const handleKeyUp = (e: KeyboardEvent) => { + if (e.code === "Space") setIsSpacePressed(false); + }; + window.addEventListener("keydown", handleKeyDown); + window.addEventListener("keyup", handleKeyUp); + return () => { + window.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("keyup", handleKeyUp); + }; + }, [isSpacePressed]); + + // 컴포넌트 드롭 + const [{ isOver, canDrop }, drop] = useDrop( + () => ({ + accept: DND_ITEM_TYPES.COMPONENT, + drop: (item: DragItemComponent) => { + // 루트 컨테이너에 추가 + onDropComponent(item.componentType, "root"); + }, + collect: (monitor) => ({ + isOver: monitor.isOver(), + canDrop: monitor.canDrop(), + }), + }), + [onDropComponent] + ); + + drop(dropRef); + + return ( +
+ {/* 툴바 */} +
+ {/* 뷰포트 프리셋 (4개 모드) */} +
+ 미리보기: + {VIEWPORT_PRESETS.map((preset) => { + const Icon = preset.icon; + const isActive = activeViewport === preset.id; + const isDefault = preset.id === DEFAULT_PRESET; + return ( + + ); + })} +
+ + {/* 줌 컨트롤 */} +
+ + {Math.round(canvasScale * 100)}% + + + + +
+
+ + {/* 뷰포트 크기 슬라이더 */} +
+ 너비: + handleWidthChange(Number(e.target.value))} + className="flex-1 h-1 bg-gray-300 rounded-lg appearance-none cursor-pointer" + /> + + {customWidth} x {viewportHeight} + +
+ + {/* 캔버스 영역 */} +
+
+ {/* 디바이스 프레임 */} +
+ {/* 뷰포트 라벨 */} +
+ {currentPreset.label} ({viewportWidth}x{viewportHeight}) +
+ + {/* Flexbox 렌더러 - 최소 높이는 뷰포트 높이, 컨텐츠에 따라 늘어남 */} +
+ { + onSelectComponent(null); + onSelectContainer(null); + }} + onComponentResize={onResizeComponent} + onReorderComponent={onReorderComponent} + /> +
+ + {/* 드롭 안내 (빈 상태) */} + {layout.root.children.length === 0 && ( +
+
+

+ {isOver && canDrop + ? "여기에 놓으세요" + : "컴포넌트를 드래그하세요"} +

+

+ 왼쪽 패널에서 컴포넌트를 드래그하여 추가 +

+
+
+ )} +
+
+
+
+ ); +} + +export default PopCanvasV4; diff --git a/frontend/components/pop/designer/PopDesigner.tsx b/frontend/components/pop/designer/PopDesigner.tsx index 682d6395..182ea3b7 100644 --- a/frontend/components/pop/designer/PopDesigner.tsx +++ b/frontend/components/pop/designer/PopDesigner.tsx @@ -3,7 +3,7 @@ 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 { ArrowLeft, Save, Smartphone, Tablet, Undo2, Redo2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { @@ -14,26 +14,41 @@ import { import { toast } from "sonner"; import { PopCanvas } from "./PopCanvas"; +import { PopCanvasV4 } from "./PopCanvasV4"; import { PopPanel } from "./panels/PopPanel"; +import { ComponentPaletteV4 } from "./panels/ComponentPaletteV4"; +import { ComponentEditorPanelV4 } from "./panels/ComponentEditorPanelV4"; import { PopLayoutDataV3, + PopLayoutDataV4, PopLayoutModeKey, PopComponentType, GridPosition, PopComponentDefinition, + PopComponentDefinitionV4, + PopContainerV4, + PopSizeConstraintV4, createEmptyPopLayoutV3, + createEmptyPopLayoutV4, ensureV3Layout, addComponentToV3Layout, removeComponentFromV3Layout, updateComponentPositionInModeV3, + addComponentToV4Layout, + removeComponentFromV4Layout, + updateComponentInV4Layout, + updateContainerV4, + findContainerV4, isV3Layout, + isV4Layout, } from "./types/pop-layout"; import { screenApi } from "@/lib/api/screen"; import { ScreenDefinition } from "@/types/screen"; // ======================================== -// 디바이스 타입 +// 레이아웃 모드 타입 // ======================================== +type LayoutMode = "v3" | "v4"; type DeviceType = "mobile" | "tablet"; // ======================================== @@ -46,7 +61,9 @@ interface PopDesignerProps { } // ======================================== -// 메인 컴포넌트 (v3: 섹션 없이 컴포넌트 직접 배치) +// 메인 컴포넌트 (v3/v4 통합) +// - 새 화면: v4로 시작 +// - 기존 v3 화면: v3로 로드 (하위 호환) // ======================================== export default function PopDesigner({ selectedScreen, @@ -54,27 +71,129 @@ export default function PopDesigner({ onScreenUpdate, }: PopDesignerProps) { // ======================================== - // 레이아웃 상태 (v3) + // 레이아웃 모드 (데이터에 따라 자동 결정) // ======================================== - const [layout, setLayout] = useState(createEmptyPopLayoutV3()); + const [layoutMode, setLayoutMode] = useState("v4"); + + // ======================================== + // 레이아웃 상태 (데스크탑 모드와 동일한 방식) + // ======================================== + const [layoutV4, setLayoutV4] = useState(createEmptyPopLayoutV4()); + const [layoutV3, setLayoutV3] = useState(createEmptyPopLayoutV3()); + + // 히스토리 (v4용) + const [historyV4, setHistoryV4] = useState([]); + const [historyIndexV4, setHistoryIndexV4] = useState(-1); + + // 히스토리 (v3용) + const [historyV3, setHistoryV3] = useState([]); + const [historyIndexV3, setHistoryIndexV3] = useState(-1); + + const [idCounter, setIdCounter] = useState(1); + const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); const [hasChanges, setHasChanges] = useState(false); // ======================================== - // 디바이스/모드 상태 + // 히스토리 저장 함수 + // ======================================== + const saveToHistoryV4 = useCallback((newLayout: PopLayoutDataV4) => { + setHistoryV4((prev) => { + const newHistory = prev.slice(0, historyIndexV4 + 1); + newHistory.push(JSON.parse(JSON.stringify(newLayout))); // 깊은 복사 + return newHistory.slice(-50); // 최대 50개 + }); + setHistoryIndexV4((prev) => Math.min(prev + 1, 49)); + }, [historyIndexV4]); + + const saveToHistoryV3 = useCallback((newLayout: PopLayoutDataV3) => { + setHistoryV3((prev) => { + const newHistory = prev.slice(0, historyIndexV3 + 1); + newHistory.push(JSON.parse(JSON.stringify(newLayout))); + return newHistory.slice(-50); + }); + setHistoryIndexV3((prev) => Math.min(prev + 1, 49)); + }, [historyIndexV3]); + + // ======================================== + // Undo/Redo 함수 + // ======================================== + const undoV4 = useCallback(() => { + if (historyIndexV4 > 0) { + const newIndex = historyIndexV4 - 1; + const previousLayout = historyV4[newIndex]; + if (previousLayout) { + setLayoutV4(JSON.parse(JSON.stringify(previousLayout))); + setHistoryIndexV4(newIndex); + console.log("[Undo V4] 복원됨, index:", newIndex); + } + } + }, [historyIndexV4, historyV4]); + + const redoV4 = useCallback(() => { + if (historyIndexV4 < historyV4.length - 1) { + const newIndex = historyIndexV4 + 1; + const nextLayout = historyV4[newIndex]; + if (nextLayout) { + setLayoutV4(JSON.parse(JSON.stringify(nextLayout))); + setHistoryIndexV4(newIndex); + console.log("[Redo V4] 복원됨, index:", newIndex); + } + } + }, [historyIndexV4, historyV4]); + + const undoV3 = useCallback(() => { + if (historyIndexV3 > 0) { + const newIndex = historyIndexV3 - 1; + const previousLayout = historyV3[newIndex]; + if (previousLayout) { + setLayoutV3(JSON.parse(JSON.stringify(previousLayout))); + setHistoryIndexV3(newIndex); + } + } + }, [historyIndexV3, historyV3]); + + const redoV3 = useCallback(() => { + if (historyIndexV3 < historyV3.length - 1) { + const newIndex = historyIndexV3 + 1; + const nextLayout = historyV3[newIndex]; + if (nextLayout) { + setLayoutV3(JSON.parse(JSON.stringify(nextLayout))); + setHistoryIndexV3(newIndex); + } + } + }, [historyIndexV3, historyV3]); + + // 현재 모드의 Undo/Redo + const canUndo = layoutMode === "v4" ? historyIndexV4 > 0 : historyIndexV3 > 0; + const canRedo = layoutMode === "v4" + ? historyIndexV4 < historyV4.length - 1 + : historyIndexV3 < historyV3.length - 1; + const handleUndo = layoutMode === "v4" ? undoV4 : undoV3; + const handleRedo = layoutMode === "v4" ? redoV4 : redoV3; + + // ======================================== + // v3용 디바이스/모드 상태 // ======================================== const [activeDevice, setActiveDevice] = useState("tablet"); const [activeModeKey, setActiveModeKey] = useState("tablet_landscape"); // ======================================== - // 선택 상태 (v3: 섹션 없음, 컴포넌트만) + // 선택 상태 // ======================================== const [selectedComponentId, setSelectedComponentId] = useState(null); + const [selectedContainerId, setSelectedContainerId] = useState(null); - // 선택된 컴포넌트 정의 - const selectedComponent: PopComponentDefinition | null = selectedComponentId - ? layout.components[selectedComponentId] || null + // 선택된 컴포넌트/컨테이너 + const selectedComponentV3: PopComponentDefinition | null = selectedComponentId + ? layoutV3.components[selectedComponentId] || null + : null; + const selectedComponentV4: PopComponentDefinitionV4 | null = selectedComponentId + ? layoutV4.components[selectedComponentId] || null + : null; + const selectedContainer: PopContainerV4 | null = selectedContainerId + ? findContainerV4(layoutV4.root, selectedContainerId) : null; // ======================================== @@ -87,26 +206,46 @@ export default function PopDesigner({ setIsLoading(true); try { const loadedLayout = await screenApi.getLayoutPop(selectedScreen.screenId); + + // 유효한 레이아웃인지 확인: + // 1. version 필드 필수 + // 2. 컴포넌트가 있어야 함 (빈 레이아웃은 새 화면 취급) + const hasValidLayout = loadedLayout && loadedLayout.version; + const hasComponents = loadedLayout?.components && Object.keys(loadedLayout.components).length > 0; - 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 자동 마이그레이션 완료"); + if (hasValidLayout && hasComponents) { + if (isV4Layout(loadedLayout)) { + // v4 레이아웃 + setLayoutV4(loadedLayout); + setHistoryV4([loadedLayout]); + setHistoryIndexV4(0); + setLayoutMode("v4"); + console.log(`POP v4 레이아웃 로드: ${Object.keys(loadedLayout.components).length}개 컴포넌트`); + } else { + // v1/v2/v3 → v3로 변환 + const v3Layout = ensureV3Layout(loadedLayout); + setLayoutV3(v3Layout); + setHistoryV3([v3Layout]); + setHistoryIndexV3(0); + setLayoutMode("v3"); + console.log(`POP v3 레이아웃 로드: ${Object.keys(v3Layout.components).length}개 컴포넌트`); } } else { - console.log("POP 레이아웃 없음, 빈 v3 레이아웃 생성"); - setLayout(createEmptyPopLayoutV3()); + // 새 화면 또는 빈 레이아웃 → v4로 시작 + const emptyLayout = createEmptyPopLayoutV4(); + setLayoutV4(emptyLayout); + setHistoryV4([emptyLayout]); + setHistoryIndexV4(0); + setLayoutMode("v4"); } } catch (error) { console.error("레이아웃 로드 실패:", error); toast.error("레이아웃을 불러오는데 실패했습니다"); - setLayout(createEmptyPopLayoutV3()); + const emptyLayout = createEmptyPopLayoutV4(); + setLayoutV4(emptyLayout); + setHistoryV4([emptyLayout]); + setHistoryIndexV4(0); + setLayoutMode("v4"); } finally { setIsLoading(false); } @@ -123,7 +262,8 @@ export default function PopDesigner({ setIsSaving(true); try { - await screenApi.saveLayoutPop(selectedScreen.screenId, layout); + const layoutToSave = layoutMode === "v3" ? layoutV3 : layoutV4; + await screenApi.saveLayoutPop(selectedScreen.screenId, layoutToSave); toast.success("저장되었습니다"); setHasChanges(false); } catch (error) { @@ -132,73 +272,180 @@ export default function PopDesigner({ } finally { setIsSaving(false); } - }, [selectedScreen?.screenId, layout]); + }, [selectedScreen?.screenId, layoutMode, layoutV3, layoutV4]); // ======================================== - // 컴포넌트 추가 (4모드 동기화) + // v3: 컴포넌트 핸들러 // ======================================== - const handleDropComponent = useCallback( + const handleDropComponentV3 = useCallback( (type: PopComponentType, gridPosition: GridPosition) => { const newId = `${type}-${Date.now()}`; - setLayout((prev) => addComponentToV3Layout(prev, newId, type, gridPosition)); + const newLayout = addComponentToV3Layout(layoutV3, newId, type, gridPosition); + setLayoutV3(newLayout); + saveToHistoryV3(newLayout); setSelectedComponentId(newId); setHasChanges(true); }, - [] + [layoutV3, saveToHistoryV3] ); - // ======================================== - // 컴포넌트 정의 업데이트 - // ======================================== - const handleUpdateComponentDefinition = useCallback( + const handleUpdateComponentDefinitionV3 = useCallback( (componentId: string, updates: Partial) => { - setLayout((prev) => ({ - ...prev, + const newLayout = { + ...layoutV3, components: { - ...prev.components, - [componentId]: { - ...prev.components[componentId], - ...updates, - }, + ...layoutV3.components, + [componentId]: { ...layoutV3.components[componentId], ...updates }, }, - })); + }; + setLayoutV3(newLayout); + saveToHistoryV3(newLayout); setHasChanges(true); }, - [] + [layoutV3, saveToHistoryV3] ); - // ======================================== - // 컴포넌트 위치 업데이트 (현재 모드만) - // ======================================== - const handleUpdateComponentPosition = useCallback( + const handleUpdateComponentPositionV3 = useCallback( (componentId: string, position: GridPosition, modeKey?: PopLayoutModeKey) => { const targetMode = modeKey || activeModeKey; - setLayout((prev) => updateComponentPositionInModeV3(prev, targetMode, componentId, position)); + const newLayout = updateComponentPositionInModeV3(layoutV3, targetMode, componentId, position); + setLayoutV3(newLayout); + saveToHistoryV3(newLayout); setHasChanges(true); }, - [activeModeKey] + [layoutV3, activeModeKey, saveToHistoryV3] + ); + + const handleDeleteComponentV3 = useCallback((componentId: string) => { + const newLayout = removeComponentFromV3Layout(layoutV3, componentId); + setLayoutV3(newLayout); + saveToHistoryV3(newLayout); + setSelectedComponentId(null); + setHasChanges(true); + }, [layoutV3, saveToHistoryV3]); + + // ======================================== + // v4: 컴포넌트 핸들러 + // ======================================== + const handleDropComponentV4 = useCallback( + (type: PopComponentType, containerId: string) => { + const componentId = `comp_${idCounter}`; + setIdCounter((prev) => prev + 1); + const newLayout = addComponentToV4Layout(layoutV4, componentId, type, containerId, `${type} ${idCounter}`); + setLayoutV4(newLayout); + saveToHistoryV4(newLayout); + setSelectedComponentId(componentId); + setSelectedContainerId(null); + setHasChanges(true); + console.log("[V4] 컴포넌트 추가, 히스토리 저장됨"); + }, + [idCounter, layoutV4, saveToHistoryV4] + ); + + const handleUpdateComponentV4 = useCallback( + (componentId: string, updates: Partial) => { + const newLayout = updateComponentInV4Layout(layoutV4, componentId, updates); + setLayoutV4(newLayout); + saveToHistoryV4(newLayout); + setHasChanges(true); + }, + [layoutV4, saveToHistoryV4] + ); + + const handleUpdateContainerV4 = useCallback( + (containerId: string, updates: Partial) => { + const newLayout = { + ...layoutV4, + root: updateContainerV4(layoutV4.root, containerId, updates), + }; + setLayoutV4(newLayout); + saveToHistoryV4(newLayout); + setHasChanges(true); + }, + [layoutV4, saveToHistoryV4] + ); + + const handleDeleteComponentV4 = useCallback((componentId: string) => { + const newLayout = removeComponentFromV4Layout(layoutV4, componentId); + setLayoutV4(newLayout); + saveToHistoryV4(newLayout); + setSelectedComponentId(null); + setHasChanges(true); + console.log("[V4] 컴포넌트 삭제, 히스토리 저장됨"); + }, [layoutV4, saveToHistoryV4]); + + // v4: 컴포넌트 크기 조정 (드래그) - 리사이즈 중에는 히스토리 저장 안 함 + // 리사이즈 완료 시 별도로 저장해야 함 (TODO: 드래그 종료 시 저장) + const handleResizeComponentV4 = useCallback( + (componentId: string, sizeUpdates: Partial) => { + const existingComponent = layoutV4.components[componentId]; + if (!existingComponent) return; + + const newLayout = { + ...layoutV4, + components: { + ...layoutV4.components, + [componentId]: { + ...existingComponent, + size: { + ...existingComponent.size, + ...sizeUpdates, + }, + }, + }, + }; + setLayoutV4(newLayout); + // 리사이즈 중에는 히스토리 저장 안 함 (너무 많아짐) + // saveToHistoryV4(newLayout); + setHasChanges(true); + }, + [layoutV4] + ); + + // v4: 컴포넌트 순서 변경 (드래그 앤 드롭) + const handleReorderComponentV4 = useCallback( + (containerId: string, fromIndex: number, toIndex: number) => { + // 컨테이너 찾기 (재귀) + const reorderInContainer = (container: PopContainerV4): PopContainerV4 => { + if (container.id === containerId) { + const newChildren = [...container.children]; + const [movedItem] = newChildren.splice(fromIndex, 1); + newChildren.splice(toIndex, 0, movedItem); + return { ...container, children: newChildren }; + } + + // 자식 컨테이너에서도 찾기 + return { + ...container, + children: container.children.map(child => { + if (typeof child === "object") { + return reorderInContainer(child); + } + return child; + }), + }; + }; + + const newLayout = { + ...layoutV4, + root: reorderInContainer(layoutV4.root), + }; + setLayoutV4(newLayout); + saveToHistoryV4(newLayout); + setHasChanges(true); + console.log("[V4] 컴포넌트 순서 변경", { containerId, fromIndex, toIndex }); + }, + [layoutV4, saveToHistoryV4] ); // ======================================== - // 컴포넌트 삭제 (4모드 동기화) - // ======================================== - const handleDeleteComponent = useCallback((componentId: string) => { - setLayout((prev) => removeComponentFromV3Layout(prev, componentId)); - setSelectedComponentId(null); - setHasChanges(true); - }, []); - - // ======================================== - // 디바이스 전환 + // v3: 디바이스/모드 전환 // ======================================== const handleDeviceChange = useCallback((device: DeviceType) => { setActiveDevice(device); setActiveModeKey(device === "tablet" ? "tablet_landscape" : "mobile_landscape"); }, []); - // ======================================== - // 모드 키 전환 - // ======================================== const handleModeKeyChange = useCallback((modeKey: PopLayoutModeKey) => { setActiveModeKey(modeKey); }, []); @@ -217,33 +464,72 @@ export default function PopDesigner({ }, [hasChanges, onBackToList]); // ======================================== - // Delete 키 삭제 기능 + // 단축키 처리 (Delete, Undo, Redo) // ======================================== useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { const target = e.target as HTMLElement; - if ( - target.tagName === "INPUT" || - target.tagName === "TEXTAREA" || - target.isContentEditable - ) { + 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); + layoutMode === "v3" ? handleDeleteComponentV3(selectedComponentId) : handleDeleteComponentV4(selectedComponentId); } } + + // Ctrl+Z / Cmd+Z: Undo (Shift 안 눌림) + if (isCtrlOrCmd && key === "z" && !e.shiftKey) { + e.preventDefault(); + console.log("Undo 시도:", { canUndo, layoutMode }); + if (canUndo) { + handleUndo(); + setHasChanges(true); + toast.success("실행 취소됨"); + } else { + toast.info("실행 취소할 내용이 없습니다"); + } + return; + } + + // Ctrl+Shift+Z / Cmd+Shift+Z: Redo + if (isCtrlOrCmd && key === "z" && e.shiftKey) { + e.preventDefault(); + console.log("Redo 시도:", { canRedo, layoutMode }); + if (canRedo) { + handleRedo(); + setHasChanges(true); + toast.success("다시 실행됨"); + } else { + toast.info("다시 실행할 내용이 없습니다"); + } + return; + } + + // Ctrl+Y / Cmd+Y: Redo (대체) + if (isCtrlOrCmd && key === "y") { + e.preventDefault(); + if (canRedo) { + handleRedo(); + setHasChanges(true); + toast.success("다시 실행됨"); + } + return; + } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [selectedComponentId, handleDeleteComponent]); + }, [selectedComponentId, layoutMode, handleDeleteComponentV3, handleDeleteComponentV4, canUndo, canRedo, handleUndo, handleRedo]); // ======================================== - // 로딩 상태 + // 로딩 // ======================================== if (isLoading) { return ( @@ -270,37 +556,67 @@ export default function PopDesigner({ {selectedScreen?.screenName || "POP 화면"} - {hasChanges && ( - *변경됨 + {hasChanges && *변경됨} + + + {/* 중앙: 레이아웃 버전 + v3 디바이스 전환 */} +
+ + {layoutMode === "v4" ? "자동 레이아웃 (v4)" : "4모드 레이아웃 (v3)"} + + + {layoutMode === "v3" && ( + handleDeviceChange(v as DeviceType)}> + + + + 태블릿 + + + + 모바일 + + + )}
- {/* 중앙: 디바이스 전환 */} + {/* 오른쪽: Undo/Redo + 저장 */}
- handleDeviceChange(v as DeviceType)} - > - - - - 태블릿 - - - - 모바일 - - - -
- - {/* 오른쪽: 저장 */} -
- + +
+ + {/* 저장 버튼 */} + @@ -309,40 +625,78 @@ export default function PopDesigner({ {/* 메인 영역 */} - {/* 왼쪽: 패널 */} - - + {/* 왼쪽: 컴포넌트 패널 */} + + {layoutMode === "v3" ? ( + + ) : ( + + )} - {/* 오른쪽: 캔버스 */} - - + {/* 중앙: 캔버스 */} + + {layoutMode === "v3" ? ( + + ) : ( + + )} + + {/* 오른쪽: 속성 패널 (v4만) */} + {layoutMode === "v4" && ( + <> + + + handleUpdateComponentV4(selectedComponentId, updates) + : undefined + } + onUpdateContainer={ + selectedContainerId + ? (updates) => handleUpdateContainerV4(selectedContainerId, updates) + : undefined + } + /> + + + )} diff --git a/frontend/components/pop/designer/panels/ComponentEditorPanelV4.tsx b/frontend/components/pop/designer/panels/ComponentEditorPanelV4.tsx new file mode 100644 index 00000000..dd601566 --- /dev/null +++ b/frontend/components/pop/designer/panels/ComponentEditorPanelV4.tsx @@ -0,0 +1,609 @@ +"use client"; + +import React from "react"; +import { cn } from "@/lib/utils"; +import { + PopComponentDefinitionV4, + PopSizeConstraintV4, + PopContainerV4, + PopComponentType, +} from "../types/pop-layout"; +import { + Settings, + Database, + Link2, + MoveHorizontal, + MoveVertical, + Square, + Maximize2, + AlignCenter, +} from "lucide-react"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +// ======================================== +// Props 정의 +// ======================================== + +interface ComponentEditorPanelV4Props { + /** 선택된 컴포넌트 */ + component: PopComponentDefinitionV4 | null; + /** 선택된 컨테이너 */ + container: PopContainerV4 | null; + /** 컴포넌트 업데이트 */ + onUpdateComponent?: (updates: Partial) => void; + /** 컨테이너 업데이트 */ + onUpdateContainer?: (updates: Partial) => void; + /** 추가 className */ + className?: string; +} + +// ======================================== +// 컴포넌트 타입별 라벨 +// ======================================== +const COMPONENT_TYPE_LABELS: Record = { + "pop-field": "필드", + "pop-button": "버튼", + "pop-list": "리스트", + "pop-indicator": "인디케이터", + "pop-scanner": "스캐너", + "pop-numpad": "숫자패드", +}; + +// ======================================== +// v4 컴포넌트 편집 패널 +// +// 핵심: +// - 크기 제약 편집 (fixed/fill/hug) +// - 반응형 숨김 설정 +// - 개별 정렬 설정 +// ======================================== + +export function ComponentEditorPanelV4({ + component, + container, + onUpdateComponent, + onUpdateContainer, + className, +}: ComponentEditorPanelV4Props) { + // 아무것도 선택되지 않은 경우 + if (!component && !container) { + return ( +
+
+

속성

+
+
+ 컴포넌트 또는 컨테이너를 선택하세요 +
+
+ ); + } + + // 컨테이너가 선택된 경우 + if (container) { + return ( +
+
+

컨테이너 설정

+

{container.id}

+
+
+ +
+
+ ); + } + + // 컴포넌트가 선택된 경우 + return ( +
+ {/* 헤더 */} +
+

+ {component!.label || COMPONENT_TYPE_LABELS[component!.type]} +

+

{component!.type}

+
+ + {/* 탭 컨텐츠 */} + + + + + 크기 + + + + 설정 + + + + 데이터 + + + + {/* 크기 제약 탭 */} + + + + + {/* 기본 설정 탭 */} + + + + + {/* 데이터 바인딩 탭 */} + + + + +
+ ); +} + +// ======================================== +// 크기 제약 폼 +// ======================================== + +interface SizeConstraintFormProps { + component: PopComponentDefinitionV4; + onUpdate?: (updates: Partial) => void; +} + +function SizeConstraintForm({ component, onUpdate }: SizeConstraintFormProps) { + const { size } = component; + + const handleSizeChange = ( + field: keyof PopSizeConstraintV4, + value: string | number | undefined + ) => { + onUpdate?.({ + size: { + ...size, + [field]: value, + }, + }); + }; + + return ( +
+ {/* 너비 설정 */} +
+ + +
+ handleSizeChange("width", "fixed")} + label="고정" + description="px" + /> + handleSizeChange("width", "fill")} + label="채움" + description="flex" + /> + handleSizeChange("width", "hug")} + label="맞춤" + description="auto" + /> +
+ + {/* 고정 너비 입력 */} + {size.width === "fixed" && ( +
+ + handleSizeChange( + "fixedWidth", + e.target.value ? Number(e.target.value) : undefined + ) + } + placeholder="너비" + /> + px +
+ )} + + {/* 채움일 때 최소/최대 */} + {size.width === "fill" && ( +
+ + handleSizeChange( + "minWidth", + e.target.value ? Number(e.target.value) : undefined + ) + } + placeholder="최소" + /> + ~ + + handleSizeChange( + "maxWidth", + e.target.value ? Number(e.target.value) : undefined + ) + } + placeholder="최대" + /> + px +
+ )} +
+ + {/* 높이 설정 */} +
+ + +
+ handleSizeChange("height", "fixed")} + label="고정" + description="px" + /> + handleSizeChange("height", "fill")} + label="채움" + description="flex" + /> + handleSizeChange("height", "hug")} + label="맞춤" + description="auto" + /> +
+ + {/* 고정 높이 입력 */} + {size.height === "fixed" && ( +
+ + handleSizeChange( + "fixedHeight", + e.target.value ? Number(e.target.value) : undefined + ) + } + placeholder="높이" + /> + px +
+ )} + + {/* 채움일 때 최소 */} + {size.height === "fill" && ( +
+ + handleSizeChange( + "minHeight", + e.target.value ? Number(e.target.value) : undefined + ) + } + placeholder="최소 높이" + /> + px +
+ )} +
+ + {/* 개별 정렬 */} +
+ + +
+ + {/* 반응형 숨김 */} +
+ +
+ + onUpdate?.({ + hideBelow: e.target.value ? Number(e.target.value) : undefined, + }) + } + placeholder="없음" + /> + px 이하에서 숨김 +
+
+
+ ); +} + +// ======================================== +// 크기 버튼 컴포넌트 +// ======================================== + +interface SizeButtonProps { + active: boolean; + onClick: () => void; + label: string; + description: string; +} + +function SizeButton({ active, onClick, label, description }: SizeButtonProps) { + return ( + + ); +} + +// ======================================== +// 컨테이너 설정 폼 +// ======================================== + +interface ContainerSettingsFormProps { + container: PopContainerV4; + onUpdate?: (updates: Partial) => void; +} + +function ContainerSettingsForm({ + container, + onUpdate, +}: ContainerSettingsFormProps) { + return ( +
+ {/* 방향 */} +
+ +
+ + +
+
+ + {/* 줄바꿈 */} +
+ +
+ + +
+
+ + {/* 간격 */} +
+ +
+ onUpdate?.({ gap: Number(e.target.value) || 0 })} + /> + px +
+
+ + {/* 패딩 */} +
+ +
+ + onUpdate?.({ padding: Number(e.target.value) || undefined }) + } + /> + px +
+
+ + {/* 정렬 */} +
+ + +
+ +
+ + +
+
+ ); +} + +// ======================================== +// 컴포넌트 설정 폼 +// ======================================== + +interface ComponentSettingsFormProps { + component: PopComponentDefinitionV4; + onUpdate?: (updates: Partial) => void; +} + +function ComponentSettingsForm({ + component, + onUpdate, +}: ComponentSettingsFormProps) { + return ( +
+ {/* 라벨 입력 */} +
+ + onUpdate?.({ label: e.target.value })} + placeholder="컴포넌트 라벨" + /> +
+ + {/* 타입별 설정 (TODO: 상세 구현) */} +
+

+ {COMPONENT_TYPE_LABELS[component.type]} 상세 설정 +

+

+ (추후 구현 예정) +

+
+
+ ); +} + +// ======================================== +// 데이터 바인딩 플레이스홀더 +// ======================================== + +function DataBindingPlaceholder() { + return ( +
+
+
+ +

+ 데이터 바인딩 설정 +

+

+ 테이블 선택 - 칼럼 선택 - 조인 설정 +

+

+ (추후 구현 예정) +

+
+
+
+ ); +} + +export default ComponentEditorPanelV4; diff --git a/frontend/components/pop/designer/panels/ComponentPaletteV4.tsx b/frontend/components/pop/designer/panels/ComponentPaletteV4.tsx new file mode 100644 index 00000000..40d15726 --- /dev/null +++ b/frontend/components/pop/designer/panels/ComponentPaletteV4.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { useDrag } from "react-dnd"; +import { + Type, + MousePointer, + List, + Activity, + ScanLine, + Calculator, + GripVertical, + Space, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { PopComponentType } from "../types/pop-layout"; +import { DND_ITEM_TYPES, DragItemComponent } from "./PopPanel"; + +// ======================================== +// 컴포넌트 팔레트 정의 +// ======================================== +const COMPONENT_PALETTE: { + type: PopComponentType; + label: string; + icon: React.ElementType; + description: string; +}[] = [ + { + type: "pop-field", + label: "필드", + icon: Type, + description: "텍스트, 숫자 등 데이터 입력", + }, + { + type: "pop-button", + label: "버튼", + icon: MousePointer, + description: "저장, 삭제 등 액션 실행", + }, + { + type: "pop-list", + label: "리스트", + icon: List, + description: "데이터 목록 (카드 템플릿 지원)", + }, + { + type: "pop-indicator", + label: "인디케이터", + icon: Activity, + description: "KPI, 상태 표시", + }, + { + type: "pop-scanner", + label: "스캐너", + icon: ScanLine, + description: "바코드/QR 스캔", + }, + { + type: "pop-numpad", + label: "숫자패드", + icon: Calculator, + description: "숫자 입력 전용", + }, + { + type: "pop-spacer", + label: "스페이서", + icon: Space, + description: "빈 공간 (정렬용)", + }, +]; + +// ======================================== +// v4 컴포넌트 팔레트 +// ======================================== +export function ComponentPaletteV4() { + return ( +
+ {/* 헤더 */} +
+
+ 편집 중: v4 (자동 반응형) +
+
+ 규칙 기반 레이아웃 +
+
+ + {/* 컴포넌트 목록 */} +
+
+ 컴포넌트 +
+
+ {COMPONENT_PALETTE.map((item) => ( + + ))} +
+
+ 캔버스로 드래그하여 배치 +
+
+
+ ); +} + +// ======================================== +// 드래그 가능한 컴포넌트 아이템 +// ======================================== +interface DraggableComponentV4Props { + type: PopComponentType; + label: string; + icon: React.ElementType; + description: string; +} + +function DraggableComponentV4({ type, label, icon: Icon, description }: DraggableComponentV4Props) { + const [{ isDragging }, drag] = useDrag( + () => ({ + type: DND_ITEM_TYPES.COMPONENT, + item: { type: DND_ITEM_TYPES.COMPONENT, componentType: type } as DragItemComponent, + collect: (monitor) => ({ + isDragging: monitor.isDragging(), + }), + }), + [type] + ); + + return ( +
+ + +
+
{label}
+
{description}
+
+
+ ); +} + +export default ComponentPaletteV4; diff --git a/frontend/components/pop/designer/panels/index.ts b/frontend/components/pop/designer/panels/index.ts index 5fc9ebf1..baeb4880 100644 --- a/frontend/components/pop/designer/panels/index.ts +++ b/frontend/components/pop/designer/panels/index.ts @@ -1,3 +1,4 @@ // POP 디자이너 패널 export export { PopPanel } from "./PopPanel"; export { ComponentEditorPanel } from "./ComponentEditorPanel"; +export { ComponentEditorPanelV4 } from "./ComponentEditorPanelV4"; diff --git a/frontend/components/pop/designer/renderers/PopFlexRenderer.tsx b/frontend/components/pop/designer/renderers/PopFlexRenderer.tsx new file mode 100644 index 00000000..9da420d8 --- /dev/null +++ b/frontend/components/pop/designer/renderers/PopFlexRenderer.tsx @@ -0,0 +1,796 @@ +"use client"; + +import React, { useMemo, useState, useCallback, useRef } from "react"; +import { useDrag, useDrop } from "react-dnd"; +import { cn } from "@/lib/utils"; +import { + PopLayoutDataV4, + PopContainerV4, + PopComponentDefinitionV4, + PopResponsiveRuleV4, + PopSizeConstraintV4, + PopComponentType, +} from "../types/pop-layout"; + +// 드래그 아이템 타입 +const DND_COMPONENT_REORDER = "POP_COMPONENT_REORDER"; + +interface DragItem { + type: string; + componentId: string; + containerId: string; + index: number; +} + +// ======================================== +// Props 정의 +// ======================================== + +interface PopFlexRendererProps { + /** v4 레이아웃 데이터 */ + layout: PopLayoutDataV4; + /** 현재 뷰포트 너비 (반응형 규칙 적용용) */ + viewportWidth: number; + /** 디자인 모드 여부 */ + isDesignMode?: boolean; + /** 선택된 컴포넌트 ID */ + selectedComponentId?: string | null; + /** 컴포넌트 클릭 */ + onComponentClick?: (componentId: string) => void; + /** 컨테이너 클릭 */ + onContainerClick?: (containerId: string) => void; + /** 배경 클릭 */ + onBackgroundClick?: () => void; + /** 컴포넌트 크기 변경 */ + onComponentResize?: (componentId: string, size: Partial) => void; + /** 컴포넌트 순서 변경 */ + onReorderComponent?: (containerId: string, fromIndex: number, toIndex: number) => void; + /** 추가 className */ + className?: string; +} + +// ======================================== +// 컴포넌트 타입별 라벨 +// ======================================== +const COMPONENT_TYPE_LABELS: Record = { + "pop-field": "필드", + "pop-button": "버튼", + "pop-list": "리스트", + "pop-indicator": "인디케이터", + "pop-scanner": "스캐너", + "pop-numpad": "숫자패드", + "pop-spacer": "스페이서", +}; + +// ======================================== +// v4 Flexbox 렌더러 +// +// 핵심 역할: +// - v4 레이아웃을 Flexbox CSS로 렌더링 +// - 제약조건(fill/fixed/hug) 기반 크기 계산 +// - 반응형 규칙(breakpoint) 자동 적용 +// ======================================== + +export function PopFlexRenderer({ + layout, + viewportWidth, + isDesignMode = false, + selectedComponentId, + onComponentClick, + onContainerClick, + onBackgroundClick, + onComponentResize, + onReorderComponent, + className, +}: PopFlexRendererProps) { + const { root, components, settings } = layout; + + // 빈 상태는 PopCanvasV4에서 표시하므로 여기서는 투명 배경만 렌더링 + if (root.children.length === 0) { + return ( +
+ ); + } + + return ( +
{ + if (e.target === e.currentTarget) { + onBackgroundClick?.(); + } + }} + > + {/* 루트 컨테이너 렌더링 */} + +
+ ); +} + +// ======================================== +// 컨테이너 렌더러 (재귀) +// ======================================== + +interface ContainerRendererProps { + container: PopContainerV4; + components: Record; + viewportWidth: number; + settings: PopLayoutDataV4["settings"]; + isDesignMode?: boolean; + selectedComponentId?: string | null; + onComponentClick?: (componentId: string) => void; + onContainerClick?: (containerId: string) => void; + onComponentResize?: (componentId: string, size: Partial) => void; + onReorderComponent?: (containerId: string, fromIndex: number, toIndex: number) => void; + depth?: number; +} + +function ContainerRenderer({ + container, + components, + viewportWidth, + settings, + isDesignMode = false, + selectedComponentId, + onComponentClick, + onContainerClick, + onComponentResize, + onReorderComponent, + depth = 0, +}: ContainerRendererProps) { + // 반응형 규칙 적용 + const effectiveContainer = useMemo(() => { + return applyResponsiveRules(container, viewportWidth); + }, [container, viewportWidth]); + + // 비율 스케일 계산 (디자인 모드에서는 1, 뷰어에서는 실제 비율 적용) + const scale = isDesignMode ? 1 : viewportWidth / BASE_VIEWPORT_WIDTH; + + // Flexbox 스타일 계산 (useMemo는 조건문 전에 호출해야 함) + const containerStyle = useMemo((): React.CSSProperties => { + const { direction, wrap, gap, alignItems, justifyContent, padding } = effectiveContainer; + + // gap과 padding도 스케일 적용 + const scaledGap = gap * scale; + const scaledPadding = padding ? padding * scale : undefined; + + return { + display: "flex", + flexDirection: direction === "horizontal" ? "row" : "column", + flexWrap: wrap ? "wrap" : "nowrap", + gap: `${scaledGap}px`, + alignItems: mapAlignment(alignItems), + justifyContent: mapJustify(justifyContent), + padding: scaledPadding ? `${scaledPadding}px` : undefined, + width: "100%", + minHeight: depth === 0 ? "100%" : undefined, + }; + }, [effectiveContainer, depth, scale]); + + // 숨김 처리 + if (effectiveContainer.hidden) { + return null; + } + + return ( +
0 && "border border-dashed border-gray-300 rounded" + )} + style={containerStyle} + onClick={(e) => { + if (e.target === e.currentTarget) { + onContainerClick?.(container.id); + } + }} + > + {effectiveContainer.children.map((child, index) => { + // 중첩 컨테이너인 경우 + if (typeof child === "object") { + return ( + + ); + } + + // 컴포넌트 ID인 경우 + const componentId = child; + const compDef = components[componentId]; + if (!compDef) return null; + + // 반응형 숨김 처리 + if (compDef.hideBelow && viewportWidth < compDef.hideBelow) { + return null; + } + + return ( + + onComponentClick?.(componentId)} + onResize={onComponentResize} + /> + + ); + })} +
+ ); +} + +// ======================================== +// 드래그 가능한 컴포넌트 래퍼 +// ======================================== + +interface DraggableComponentWrapperProps { + componentId: string; + containerId: string; + index: number; + isDesignMode: boolean; + onReorder?: (containerId: string, fromIndex: number, toIndex: number) => void; + children: React.ReactNode; +} + +function DraggableComponentWrapper({ + componentId, + containerId, + index, + isDesignMode, + onReorder, + children, +}: DraggableComponentWrapperProps) { + // 디자인 모드가 아니면 그냥 children 반환 (훅 호출 전에 체크) + // DndProvider가 없는 환경에서 useDrag/useDrop 훅 호출 방지 + if (!isDesignMode) { + return <>{children}; + } + + // 디자인 모드일 때만 드래그 기능 활성화 + return ( + + {children} + + ); +} + +// 디자인 모드 전용 내부 컴포넌트 (DndProvider 필요) +function DraggableComponentWrapperInner({ + componentId, + containerId, + index, + onReorder, + children, +}: Omit) { + const ref = useRef(null); + + const [{ isDragging }, drag] = useDrag({ + type: DND_COMPONENT_REORDER, + item: (): DragItem => ({ + type: DND_COMPONENT_REORDER, + componentId, + containerId, + index, + }), + collect: (monitor) => ({ + isDragging: monitor.isDragging(), + }), + }); + + const [{ isOver, canDrop }, drop] = useDrop({ + accept: DND_COMPONENT_REORDER, + canDrop: (item: DragItem) => { + // 같은 컨테이너 내에서만 이동 가능 (일단은) + return item.containerId === containerId && item.index !== index; + }, + drop: (item: DragItem) => { + if (item.index !== index && onReorder) { + onReorder(containerId, item.index, index); + } + }, + collect: (monitor) => ({ + isOver: monitor.isOver(), + canDrop: monitor.canDrop(), + }), + }); + + // drag와 drop 합치기 + drag(drop(ref)); + + return ( +
+ {children} + {/* 드롭 인디케이터 */} + {isOver && canDrop && ( +
+ )} +
+ ); +} + +// ======================================== +// v4 컴포넌트 렌더러 (리사이즈 핸들 포함) +// ======================================== + +interface ComponentRendererV4Props { + componentId: string; + component: PopComponentDefinitionV4; + settings: PopLayoutDataV4["settings"]; + viewportWidth: number; + isDesignMode?: boolean; + isSelected?: boolean; + onClick?: () => void; + onResize?: (componentId: string, size: Partial) => void; +} + +function ComponentRendererV4({ + componentId, + component, + settings, + viewportWidth, + isDesignMode = false, + isSelected = false, + onClick, + onResize, +}: ComponentRendererV4Props) { + const { size, alignSelf, type, label } = component; + const containerRef = useRef(null); + + // 비율 스케일 계산 (디자인 모드에서는 1, 뷰어에서는 실제 비율 적용) + const scale = isDesignMode ? 1 : viewportWidth / BASE_VIEWPORT_WIDTH; + + // 리사이즈 상태 + const [isResizing, setIsResizing] = useState(false); + const [resizeDirection, setResizeDirection] = useState<"width" | "height" | "both" | null>(null); + const resizeStartRef = useRef<{ x: number; y: number; width: number; height: number } | null>(null); + + // 크기 스타일 계산 (스케일 적용) + const sizeStyle = useMemo((): React.CSSProperties => { + return calculateSizeStyle(size, settings, scale); + }, [size, settings, scale]); + + // alignSelf 스타일 + const alignStyle: React.CSSProperties = alignSelf + ? { alignSelf: mapAlignment(alignSelf) } + : {}; + + const typeLabel = COMPONENT_TYPE_LABELS[type] || type; + + // 리사이즈 시작 + const handleResizeStart = useCallback(( + e: React.MouseEvent, + direction: "width" | "height" | "both" + ) => { + e.stopPropagation(); + e.preventDefault(); + + if (!containerRef.current) return; + + const rect = containerRef.current.getBoundingClientRect(); + resizeStartRef.current = { + x: e.clientX, + y: e.clientY, + width: rect.width, + height: rect.height, + }; + setIsResizing(true); + setResizeDirection(direction); + }, []); + + // 리사이즈 중 + useCallback((e: MouseEvent) => { + if (!isResizing || !resizeStartRef.current || !onResize) return; + + const deltaX = e.clientX - resizeStartRef.current.x; + const deltaY = e.clientY - resizeStartRef.current.y; + + const updates: Partial = {}; + + if (resizeDirection === "width" || resizeDirection === "both") { + const newWidth = Math.max(48, Math.round(resizeStartRef.current.width + deltaX)); + updates.width = "fixed"; + updates.fixedWidth = newWidth; + } + + if (resizeDirection === "height" || resizeDirection === "both") { + const newHeight = Math.max(settings.touchTargetMin, Math.round(resizeStartRef.current.height + deltaY)); + updates.height = "fixed"; + updates.fixedHeight = newHeight; + } + + onResize(componentId, updates); + }, [isResizing, resizeDirection, componentId, onResize, settings.touchTargetMin]); + + // 리사이즈 종료 및 이벤트 등록 + React.useEffect(() => { + if (!isResizing) return; + + const handleMouseMove = (e: MouseEvent) => { + if (!resizeStartRef.current || !onResize) return; + + const deltaX = e.clientX - resizeStartRef.current.x; + const deltaY = e.clientY - resizeStartRef.current.y; + + const updates: Partial = {}; + + if (resizeDirection === "width" || resizeDirection === "both") { + const newWidth = Math.max(48, Math.round(resizeStartRef.current.width + deltaX)); + updates.width = "fixed"; + updates.fixedWidth = newWidth; + } + + if (resizeDirection === "height" || resizeDirection === "both") { + const newHeight = Math.max(settings.touchTargetMin, Math.round(resizeStartRef.current.height + deltaY)); + updates.height = "fixed"; + updates.fixedHeight = newHeight; + } + + onResize(componentId, updates); + }; + + const handleMouseUp = () => { + setIsResizing(false); + setResizeDirection(null); + resizeStartRef.current = null; + }; + + window.addEventListener("mousemove", handleMouseMove); + window.addEventListener("mouseup", handleMouseUp); + + return () => { + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("mouseup", handleMouseUp); + }; + }, [isResizing, resizeDirection, componentId, onResize, settings.touchTargetMin]); + + return ( +
{ + e.stopPropagation(); + if (!isResizing) { + onClick?.(); + } + }} + > + {/* 컴포넌트 라벨 (디자인 모드에서만) */} + {isDesignMode && ( +
+ + {label || typeLabel} + +
+ )} + + {/* 컴포넌트 내용 */} +
+ {renderComponentContent(component, isDesignMode, settings)} +
+ + {/* 리사이즈 핸들 (디자인 모드 + 선택 시에만) */} + {isDesignMode && isSelected && onResize && ( + <> + {/* 오른쪽 핸들 (너비 조정) */} +
handleResizeStart(e, "width")} + title="너비 조정" + > +
+
+ + {/* 아래쪽 핸들 (높이 조정) */} +
handleResizeStart(e, "height")} + title="높이 조정" + > +
+
+ + {/* 오른쪽 아래 핸들 (너비 + 높이 동시 조정) */} +
handleResizeStart(e, "both")} + title="크기 조정" + > +
+
+ + )} +
+ ); +} + +// ======================================== +// 헬퍼 함수들 +// ======================================== + +/** + * 반응형 규칙 적용 + */ +function applyResponsiveRules( + container: PopContainerV4, + viewportWidth: number +): PopContainerV4 & { hidden?: boolean } { + if (!container.responsive || container.responsive.length === 0) { + return container; + } + + // 현재 뷰포트에 적용되는 규칙 찾기 (가장 큰 breakpoint부터) + const sortedRules = [...container.responsive].sort((a, b) => b.breakpoint - a.breakpoint); + const applicableRule = sortedRules.find((rule) => viewportWidth <= rule.breakpoint); + + if (!applicableRule) { + return container; + } + + return { + ...container, + direction: applicableRule.direction ?? container.direction, + gap: applicableRule.gap ?? container.gap, + hidden: applicableRule.hidden ?? false, + }; +} + +/** + * 기준 뷰포트 너비 (10인치 태블릿 가로) + * 이 크기를 기준으로 컴포넌트 크기가 비율 조정됨 + */ +const BASE_VIEWPORT_WIDTH = 1024; + +/** + * 크기 제약 → CSS 스타일 변환 + * @param scale - 뷰포트 비율 (viewportWidth / BASE_VIEWPORT_WIDTH) + */ +function calculateSizeStyle( + size: PopSizeConstraintV4, + settings: PopLayoutDataV4["settings"], + scale: number = 1 +): React.CSSProperties { + const style: React.CSSProperties = {}; + + // 스케일된 터치 최소 크기 + const scaledTouchMin = settings.touchTargetMin * scale; + + // 너비 + switch (size.width) { + case "fixed": + // fixed 크기도 비율에 맞게 스케일 + style.width = size.fixedWidth ? `${size.fixedWidth * scale}px` : "auto"; + style.flexShrink = 0; + break; + case "fill": + style.flex = 1; + style.minWidth = size.minWidth ? `${size.minWidth * scale}px` : 0; + style.maxWidth = size.maxWidth ? `${size.maxWidth * scale}px` : undefined; + break; + case "hug": + style.width = "auto"; + style.flexShrink = 0; + break; + } + + // 높이 + switch (size.height) { + case "fixed": + const scaledFixedHeight = (size.fixedHeight || settings.touchTargetMin) * scale; + const minHeight = Math.max(scaledFixedHeight, scaledTouchMin); + style.height = `${minHeight}px`; + break; + case "fill": + style.flexGrow = 1; + style.minHeight = size.minHeight + ? `${Math.max(size.minHeight * scale, scaledTouchMin)}px` + : `${scaledTouchMin}px`; + break; + case "hug": + style.height = "auto"; + style.minHeight = `${scaledTouchMin}px`; + break; + } + + return style; +} + +/** + * alignItems 값 변환 + */ +function mapAlignment(value: string): React.CSSProperties["alignItems"] { + switch (value) { + case "start": + return "flex-start"; + case "end": + return "flex-end"; + case "center": + return "center"; + case "stretch": + return "stretch"; + default: + return "stretch"; + } +} + +/** + * justifyContent 값 변환 + */ +function mapJustify(value: string): React.CSSProperties["justifyContent"] { + switch (value) { + case "start": + return "flex-start"; + case "end": + return "flex-end"; + case "center": + return "center"; + case "space-between": + return "space-between"; + default: + return "flex-start"; + } +} + +/** + * 컴포넌트별 내용 렌더링 + */ +function renderComponentContent( + component: PopComponentDefinitionV4, + isDesignMode: boolean, + settings: PopLayoutDataV4["settings"] +): React.ReactNode { + const typeLabel = COMPONENT_TYPE_LABELS[component.type] || component.type; + + // 디자인 모드에서는 플레이스홀더 표시 + if (isDesignMode) { + // Spacer는 디자인 모드에서 점선 배경으로 표시 + if (component.type === "pop-spacer") { + return ( +
+ 빈 공간 +
+ ); + } + return ( +
+ {typeLabel} +
+ ); + } + + // 뷰어 모드: 실제 컴포넌트 렌더링 + switch (component.type) { + case "pop-field": + return ( + + ); + + case "pop-button": + return ( + + ); + + case "pop-list": + return ( +
+
+ 리스트 (데이터 연결 필요) +
+
+ ); + + case "pop-indicator": + return ( +
+
0
+
{component.label || "지표"}
+
+ ); + + case "pop-scanner": + return ( +
+
스캐너
+
탭하여 스캔
+
+ ); + + case "pop-numpad": + return ( +
+ {[1, 2, 3, 4, 5, 6, 7, 8, 9, "C", 0, "OK"].map((key) => ( + + ))} +
+ ); + + case "pop-spacer": + // 실제 모드에서 Spacer는 완전히 투명 (공간만 차지) + return null; + + default: + return ( +
+ {typeLabel} +
+ ); + } +} + +export default PopFlexRenderer; diff --git a/frontend/components/pop/designer/types/pop-layout.ts b/frontend/components/pop/designer/types/pop-layout.ts index f67f6f3d..249c300d 100644 --- a/frontend/components/pop/designer/types/pop-layout.ts +++ b/frontend/components/pop/designer/types/pop-layout.ts @@ -148,11 +148,11 @@ export const createEmptyPopLayoutV4 = (): PopLayoutDataV4 => ({ root: { id: "root", type: "stack", - direction: "vertical", - wrap: false, + direction: "horizontal", // 가로 방향 (왼쪽→오른쪽) + wrap: true, // 자동 줄바꿈 활성화 gap: 8, - alignItems: "stretch", - justifyContent: "start", + alignItems: "start", // 위쪽 정렬 + justifyContent: "start", // 왼쪽 정렬 padding: 16, children: [], }, @@ -166,6 +166,238 @@ export const createEmptyPopLayoutV4 = (): PopLayoutDataV4 => ({ }, }); +/** + * v4 컴포넌트 정의 생성 + * - 컴포넌트 타입별 기본 크기 설정 + */ +export const createComponentDefinitionV4 = ( + id: string, + type: PopComponentType, + label?: string +): PopComponentDefinitionV4 => { + // 타입별 기본 크기 설정 + const defaultSizes: Record = { + "pop-field": { + width: "fixed", + height: "fixed", + fixedWidth: 200, + fixedHeight: 48, + }, + "pop-button": { + width: "fixed", + height: "fixed", + fixedWidth: 120, + fixedHeight: 48, + }, + "pop-list": { + width: "fill", + height: "fixed", + fixedHeight: 200, + }, + "pop-indicator": { + width: "fixed", + height: "fixed", + fixedWidth: 120, + fixedHeight: 80, + }, + "pop-scanner": { + width: "fixed", + height: "fixed", + fixedWidth: 200, + fixedHeight: 48, + }, + "pop-numpad": { + width: "fixed", + height: "fixed", + fixedWidth: 200, + fixedHeight: 280, + }, + "pop-spacer": { + width: "fill", // Spacer는 기본적으로 남은 공간 채움 + height: "fixed", + fixedHeight: 48, + }, + }; + + return { + id, + type, + label, + size: defaultSizes[type] || { + width: "fixed", + height: "fixed", + fixedWidth: 100, + fixedHeight: 48, + }, + }; +}; + +/** + * v4 레이아웃에 컴포넌트 추가 + */ +export const addComponentToV4Layout = ( + layout: PopLayoutDataV4, + componentId: string, + type: PopComponentType, + containerId: string = "root", + label?: string +): PopLayoutDataV4 => { + const newLayout = { ...layout }; + + // 컴포넌트 정의 추가 + newLayout.components = { + ...newLayout.components, + [componentId]: createComponentDefinitionV4(componentId, type, label), + }; + + // 컨테이너에 컴포넌트 ID 추가 + if (containerId === "root") { + newLayout.root = { + ...newLayout.root, + children: [...newLayout.root.children, componentId], + }; + } else { + // 중첩 컨테이너의 경우 재귀적으로 찾아서 추가 + newLayout.root = addChildToContainer(newLayout.root, containerId, componentId); + } + + return newLayout; +}; + +/** + * 컨테이너에 자식 추가 (재귀) + */ +function addChildToContainer( + container: PopContainerV4, + targetId: string, + childId: string +): PopContainerV4 { + if (container.id === targetId) { + return { + ...container, + children: [...container.children, childId], + }; + } + + return { + ...container, + children: container.children.map((child) => { + if (typeof child === "object") { + return addChildToContainer(child, targetId, childId); + } + return child; + }), + }; +} + +/** + * v4 레이아웃에서 컴포넌트 삭제 + */ +export const removeComponentFromV4Layout = ( + layout: PopLayoutDataV4, + componentId: string +): PopLayoutDataV4 => { + const newLayout = { ...layout }; + + // 컴포넌트 정의 삭제 + const { [componentId]: _, ...remainingComponents } = newLayout.components; + newLayout.components = remainingComponents; + + // 컨테이너에서 컴포넌트 ID 제거 + newLayout.root = removeChildFromContainer(newLayout.root, componentId); + + return newLayout; +}; + +/** + * 컨테이너에서 자식 제거 (재귀) + */ +function removeChildFromContainer( + container: PopContainerV4, + childId: string +): PopContainerV4 { + return { + ...container, + children: container.children + .filter((child) => child !== childId) + .map((child) => { + if (typeof child === "object") { + return removeChildFromContainer(child, childId); + } + return child; + }), + }; +} + +/** + * v4 컴포넌트 업데이트 + */ +export const updateComponentInV4Layout = ( + layout: PopLayoutDataV4, + componentId: string, + updates: Partial +): PopLayoutDataV4 => { + const existingComponent = layout.components[componentId]; + if (!existingComponent) return layout; + + return { + ...layout, + components: { + ...layout.components, + [componentId]: { + ...existingComponent, + ...updates, + // size는 깊은 병합 + size: updates.size + ? { ...existingComponent.size, ...updates.size } + : existingComponent.size, + }, + }, + }; +}; + +/** + * v4 컨테이너 찾기 (재귀) + */ +export const findContainerV4 = ( + root: PopContainerV4, + containerId: string +): PopContainerV4 | null => { + if (root.id === containerId) return root; + + for (const child of root.children) { + if (typeof child === "object") { + const found = findContainerV4(child, containerId); + if (found) return found; + } + } + + return null; +}; + +/** + * v4 컨테이너 업데이트 (재귀) + */ +export const updateContainerV4 = ( + container: PopContainerV4, + containerId: string, + updates: Partial +): PopContainerV4 => { + if (container.id === containerId) { + return { ...container, ...updates }; + } + + return { + ...container, + children: container.children.map((child) => { + if (typeof child === "object") { + return updateContainerV4(child, containerId, updates); + } + return child; + }), + }; +}; + // ======================================== // 레이아웃 모드 키 (v3용) // ======================================== @@ -369,7 +601,7 @@ export interface PopComponentDefinition { /** * POP 컴포넌트 타입 - * - 6개 핵심 컴포넌트 + * - 7개 핵심 컴포넌트 (Spacer 포함) */ export type PopComponentType = | "pop-field" // 데이터 입력/표시 @@ -377,7 +609,8 @@ export type PopComponentType = | "pop-list" // 데이터 목록 (카드 템플릿 포함) | "pop-indicator" // 상태/수치 표시 | "pop-scanner" // 바코드/QR 입력 - | "pop-numpad"; // 숫자 입력 특화 + | "pop-numpad" // 숫자 입력 특화 + | "pop-spacer"; // 빈 공간 (레이아웃 정렬용) // ======================================== // 데이터 흐름 diff --git a/frontend/hooks/useLayoutHistory.ts b/frontend/hooks/useLayoutHistory.ts new file mode 100644 index 00000000..2f598983 --- /dev/null +++ b/frontend/hooks/useLayoutHistory.ts @@ -0,0 +1,207 @@ +"use client"; + +import { useState, useCallback, useRef } from "react"; + +// ======================================== +// 레이아웃 히스토리 훅 +// Undo/Redo 기능 제공 +// ======================================== + +interface HistoryState { + past: T[]; + present: T; + future: T[]; +} + +interface UseLayoutHistoryReturn { + // 현재 상태 + state: T; + // 상태 설정 (히스토리에 기록) + setState: (newState: T | ((prev: T) => T)) => void; + // Undo + undo: () => void; + // Redo + redo: () => void; + // Undo 가능 여부 + canUndo: boolean; + // Redo 가능 여부 + canRedo: boolean; + // 히스토리 초기화 (새 레이아웃 로드 시) + reset: (initialState: T) => void; + // 히스토리 크기 + historySize: number; +} + +/** + * 레이아웃 히스토리 관리 훅 + * @param initialState 초기 상태 + * @param maxHistory 최대 히스토리 개수 (기본 50) + */ +export function useLayoutHistory( + initialState: T, + maxHistory: number = 50 +): UseLayoutHistoryReturn { + const [history, setHistory] = useState>({ + past: [], + present: initialState, + future: [], + }); + + // 배치 업데이트를 위한 타이머 + const batchTimerRef = useRef(null); + const pendingStateRef = useRef(null); + + /** + * 상태 설정 (히스토리에 기록) + * 연속적인 드래그 등의 작업 시 배치 처리 + */ + const setState = useCallback( + (newState: T | ((prev: T) => T)) => { + setHistory((prev) => { + const resolvedState = + typeof newState === "function" + ? (newState as (prev: T) => T)(prev.present) + : newState; + + // 같은 상태면 무시 + if (JSON.stringify(resolvedState) === JSON.stringify(prev.present)) { + console.log("[History] 상태 동일, 무시"); + return prev; + } + + // 히스토리에 현재 상태 추가 + const newPast = [...prev.past, prev.present]; + + // 최대 히스토리 개수 제한 + if (newPast.length > maxHistory) { + newPast.shift(); + } + + console.log("[History] 상태 저장, past 크기:", newPast.length); + + return { + past: newPast, + present: resolvedState, + future: [], // Redo 히스토리 초기화 + }; + }); + }, + [maxHistory] + ); + + /** + * 배치 상태 설정 (드래그 중 연속 업데이트용) + * 마지막 상태만 히스토리에 기록 + */ + const setStateBatched = useCallback( + (newState: T | ((prev: T) => T), batchDelay: number = 300) => { + // 현재 상태 업데이트 (히스토리에는 바로 기록하지 않음) + setHistory((prev) => { + const resolvedState = + typeof newState === "function" + ? (newState as (prev: T) => T)(prev.present) + : newState; + + pendingStateRef.current = prev.present; + + return { + ...prev, + present: resolvedState, + }; + }); + + // 배치 타이머 리셋 + if (batchTimerRef.current) { + clearTimeout(batchTimerRef.current); + } + + // 일정 시간 후 히스토리에 기록 + batchTimerRef.current = setTimeout(() => { + if (pendingStateRef.current !== null) { + setHistory((prev) => { + const newPast = [...prev.past, pendingStateRef.current as T]; + if (newPast.length > maxHistory) { + newPast.shift(); + } + pendingStateRef.current = null; + return { + ...prev, + past: newPast, + future: [], + }; + }); + } + }, batchDelay); + }, + [maxHistory] + ); + + /** + * Undo - 이전 상태로 복원 + */ + const undo = useCallback(() => { + console.log("[History] Undo 호출"); + setHistory((prev) => { + console.log("[History] Undo 실행, past 크기:", prev.past.length); + if (prev.past.length === 0) { + console.log("[History] Undo 불가 - past 비어있음"); + return prev; + } + + const newPast = [...prev.past]; + const previousState = newPast.pop()!; + + console.log("[History] Undo 성공, 남은 past 크기:", newPast.length); + + return { + past: newPast, + present: previousState, + future: [prev.present, ...prev.future], + }; + }); + }, []); + + /** + * Redo - 되돌린 상태 다시 적용 + */ + const redo = useCallback(() => { + setHistory((prev) => { + if (prev.future.length === 0) { + return prev; + } + + const newFuture = [...prev.future]; + const nextState = newFuture.shift()!; + + return { + past: [...prev.past, prev.present], + present: nextState, + future: newFuture, + }; + }); + }, []); + + /** + * 히스토리 초기화 (새 레이아웃 로드 시) + */ + const reset = useCallback((initialState: T) => { + setHistory({ + past: [], + present: initialState, + future: [], + }); + }, []); + + return { + state: history.present, + setState, + undo, + redo, + canUndo: history.past.length > 0, + canRedo: history.future.length > 0, + reset, + historySize: history.past.length, + }; +} + +export default useLayoutHistory; diff --git a/popdocs/ARCHITECTURE.md b/popdocs/ARCHITECTURE.md new file mode 100644 index 00000000..1259e9cc --- /dev/null +++ b/popdocs/ARCHITECTURE.md @@ -0,0 +1,529 @@ +# POP 화면 시스템 아키텍처 + +**최종 업데이트: 2026-02-04** + +POP(Point of Production) 화면은 모바일/태블릿 환경에 최적화된 터치 기반 화면 시스템입니다. +이 문서는 POP 화면 구현에 관련된 모든 파일과 그 역할을 정리합니다. + +--- + +## 목차 + +1. [폴더 구조 개요](#1-폴더-구조-개요) +2. [App 라우팅 (app/(pop))](#2-app-라우팅-apppop) +3. [컴포넌트 (components/pop)](#3-컴포넌트-componentspop) +4. [라이브러리 (lib)](#4-라이브러리-lib) +5. [버전별 레이아웃 시스템](#5-버전별-레이아웃-시스템) +6. [데이터 흐름](#6-데이터-흐름) + +--- + +## 1. 폴더 구조 개요 + +``` +frontend/ +├── app/(pop)/ # Next.js App Router - POP 라우팅 +│ ├── layout.tsx # POP 전용 레이아웃 +│ └── pop/ +│ ├── page.tsx # POP 대시보드 (메인) +│ ├── screens/[screenId]/ # 개별 POP 화면 뷰어 +│ ├── test-v4/ # v4 렌더러 테스트 페이지 +│ └── work/ # 작업 화면 +│ +├── components/pop/ # POP 컴포넌트 라이브러리 +│ ├── designer/ # 디자이너 모듈 +│ │ ├── panels/ # 편집 패널 (좌측/우측) +│ │ ├── renderers/ # 레이아웃 렌더러 +│ │ └── types/ # 타입 정의 +│ ├── management/ # 화면 관리 모듈 +│ └── dashboard/ # 대시보드 모듈 +│ +└── lib/ + ├── api/popScreenGroup.ts # POP 화면 그룹 API + ├── registry/PopComponentRegistry.ts # 컴포넌트 레지스트리 + └── schemas/popComponentConfig.ts # 컴포넌트 설정 스키마 +``` + +--- + +## 2. App 라우팅 (app/(pop)) + +### `app/(pop)/layout.tsx` + +POP 전용 레이아웃. 데스크톱 레이아웃과 분리되어 터치 최적화 환경 제공. + +### `app/(pop)/pop/page.tsx` + +**경로**: `/pop` + +POP 메인 대시보드. 메뉴 그리드, KPI, 공지사항 등을 표시. + +### `app/(pop)/pop/screens/[screenId]/page.tsx` + +**경로**: `/pop/screens/:screenId` + +**역할**: 개별 POP 화면 뷰어 (디자인 모드 X, 실행 모드) + +**핵심 기능**: +- v3/v4 레이아웃 자동 감지 및 렌더링 +- 반응형 모드 감지 (태블릿/모바일, 가로/세로) +- 프리뷰 모드 지원 (`?preview=true`) + +```typescript +// 레이아웃 버전 감지 및 렌더링 +if (popLayoutV4) { + // v4: PopFlexRenderer 사용 + +} else if (popLayoutV3) { + // v3: PopLayoutRenderer 사용 + +} +``` + +### `app/(pop)/pop/test-v4/page.tsx` + +**경로**: `/pop/test-v4` + +**역할**: v4 레이아웃 시스템 테스트 페이지 + +**구성**: +- 왼쪽: 컴포넌트 팔레트 (PopPanel) +- 중앙: v4 캔버스 (PopCanvasV4) +- 오른쪽: 속성 패널 (ComponentEditorPanelV4) + +--- + +## 3. 컴포넌트 (components/pop) + +### 3.1 디자이너 모듈 (`designer/`) + +#### `PopDesigner.tsx` + +**역할**: POP 화면 디자이너 메인 컴포넌트 + +**핵심 기능**: +- v3/v4 모드 전환 (상단 탭) +- 레이아웃 로드/저장 +- 컴포넌트 추가/삭제/수정 +- 드래그 앤 드롭 (react-dnd) + +**상태 관리**: +```typescript +const [layoutMode, setLayoutMode] = useState<"v3" | "v4">("v3"); +const [layoutV3, setLayoutV3] = useState(...); +const [layoutV4, setLayoutV4] = useState(...); +const [selectedComponentId, setSelectedComponentId] = useState(null); +``` + +**레이아웃**: +``` +┌─────────────────────────────────────────────────┐ +│ 툴바 (뒤로가기, 화면명, 모드전환, 저장) │ +├──────────┬──────────────────────────┬──────────┤ +│ 왼쪽 │ 중앙 캔버스 │ 오른쪽 │ +│ 패널 │ │ 패널 │ +│ (20%) │ (60%) │ (20%) │ +│ │ │ (v4만) │ +└──────────┴──────────────────────────┴──────────┘ +``` + +#### `PopCanvas.tsx` (v3용) + +**역할**: v3 레이아웃용 CSS Grid 기반 캔버스 + +**핵심 기능**: +- 4개 모드 전환 (태블릿 가로/세로, 모바일 가로/세로) +- 그리드 기반 컴포넌트 배치 +- 드래그로 위치/크기 조정 + +#### `PopCanvasV4.tsx` (v4용) + +**역할**: v4 레이아웃용 Flexbox 기반 캔버스 + +**핵심 기능**: +- 단일 캔버스 + 뷰포트 프리뷰 +- 3가지 프리셋 (모바일 375px, 태블릿 768px, 데스크톱 1024px) +- 너비 슬라이더로 반응형 테스트 +- 줌 컨트롤 (30%~150%) + +```typescript +const VIEWPORT_PRESETS = [ + { id: "mobile", label: "모바일", width: 375, height: 667 }, + { id: "tablet", label: "태블릿", width: 768, height: 1024 }, + { id: "desktop", label: "데스크톱", width: 1024, height: 768 }, +]; +``` + +--- + +### 3.2 패널 모듈 (`designer/panels/`) + +#### `PopPanel.tsx` + +**역할**: 왼쪽 패널 - 컴포넌트 팔레트 & 편집 탭 + +**탭 구성**: +1. **컴포넌트 탭**: 드래그 가능한 6개 컴포넌트 +2. **편집 탭**: 선택된 컴포넌트 설정 + +**컴포넌트 팔레트**: +```typescript +const COMPONENT_PALETTE = [ + { type: "pop-field", label: "필드", description: "텍스트, 숫자 등 데이터 입력" }, + { type: "pop-button", label: "버튼", description: "저장, 삭제 등 액션 실행" }, + { type: "pop-list", label: "리스트", description: "데이터 목록" }, + { type: "pop-indicator", label: "인디케이터", description: "KPI, 상태 표시" }, + { type: "pop-scanner", label: "스캐너", description: "바코드/QR 스캔" }, + { type: "pop-numpad", label: "숫자패드", description: "숫자 입력 전용" }, +]; +``` + +**드래그 아이템 타입**: +```typescript +export const DND_ITEM_TYPES = { COMPONENT: "component" }; +export interface DragItemComponent { + type: typeof DND_ITEM_TYPES.COMPONENT; + componentType: PopComponentType; +} +``` + +#### `ComponentEditorPanelV4.tsx` + +**역할**: v4 오른쪽 패널 - 컴포넌트/컨테이너 속성 편집 + +**3개 탭**: +1. **크기 탭**: 너비/높이 제약 (fixed/fill/hug) +2. **설정 탭**: 라벨, 타입별 설정 +3. **데이터 탭**: 데이터 바인딩 (미구현) + +**크기 제약 편집**: +```typescript +// 너비/높이 모드 +type SizeMode = "fixed" | "fill" | "hug"; + +// fixed: 고정 px 값 +// fill: 남은 공간 채움 (flex: 1) +// hug: 내용에 맞춤 (width: auto) +``` + +**컨테이너 설정**: +- 방향 (horizontal/vertical) +- 줄바꿈 (wrap) +- 간격 (gap) +- 패딩 (padding) +- 정렬 (alignItems, justifyContent) + +--- + +### 3.3 렌더러 모듈 (`designer/renderers/`) + +#### `PopLayoutRenderer.tsx` (v3용) + +**역할**: v3 레이아웃을 CSS Grid로 렌더링 + +**입력**: +- `layout`: PopLayoutDataV3 +- `modeKey`: 현재 모드 (tablet_landscape 등) +- `isDesignMode`: 디자인 모드 여부 + +#### `PopFlexRenderer.tsx` (v4용) + +**역할**: v4 레이아웃을 Flexbox로 렌더링 + +**핵심 기능**: +- 컨테이너 재귀 렌더링 +- 반응형 규칙 적용 (breakpoint) +- 크기 제약 → CSS 스타일 변환 +- 컴포넌트 숨김 처리 (hideBelow) + +**크기 제약 변환 로직**: +```typescript +function calculateSizeStyle(size: PopSizeConstraintV4): React.CSSProperties { + const style: React.CSSProperties = {}; + + // 너비 + switch (size.width) { + case "fixed": style.width = `${size.fixedWidth}px`; style.flexShrink = 0; break; + case "fill": style.flex = 1; style.minWidth = size.minWidth || 0; break; + case "hug": style.width = "auto"; style.flexShrink = 0; break; + } + + // 높이 + switch (size.height) { + case "fixed": style.height = `${size.fixedHeight}px`; break; + case "fill": style.flexGrow = 1; break; + case "hug": style.height = "auto"; break; + } + + return style; +} +``` + +#### `ComponentRenderer.tsx` + +**역할**: 개별 컴포넌트 렌더링 (디자인 모드용 플레이스홀더) + +--- + +### 3.4 타입 정의 (`designer/types/`) + +#### `pop-layout.ts` + +**역할**: POP 레이아웃 전체 타입 시스템 정의 + +**파일 크기**: 1442줄 (v1~v4 모든 버전 포함) + +상세 내용은 [버전별 레이아웃 시스템](#5-버전별-레이아웃-시스템) 참조. + +--- + +### 3.5 관리 모듈 (`management/`) + +#### `PopCategoryTree.tsx` + +POP 화면 카테고리 트리 컴포넌트 + +#### `PopScreenSettingModal.tsx` + +POP 화면 설정 모달 + +#### `PopScreenPreview.tsx` + +POP 화면 미리보기 + +#### `PopScreenFlowView.tsx` + +화면 간 플로우 시각화 + +--- + +### 3.6 대시보드 모듈 (`dashboard/`) + +| 파일 | 역할 | +|------|------| +| `PopDashboard.tsx` | 대시보드 메인 컴포넌트 | +| `DashboardHeader.tsx` | 상단 헤더 (로고, 시간, 사용자) | +| `DashboardFooter.tsx` | 하단 푸터 | +| `MenuGrid.tsx` | 메뉴 그리드 (앱 아이콘 형태) | +| `KpiBar.tsx` | KPI 요약 바 | +| `NoticeBanner.tsx` | 공지 배너 | +| `NoticeList.tsx` | 공지 목록 | +| `ActivityList.tsx` | 최근 활동 목록 | + +--- + +## 4. 라이브러리 (lib) + +### `lib/api/popScreenGroup.ts` + +**역할**: POP 화면 그룹 API 클라이언트 + +**API 함수**: +```typescript +// 조회 +getPopScreenGroups(searchTerm?: string): Promise + +// 생성 +createPopScreenGroup(data: CreatePopScreenGroupRequest): Promise<...> + +// 수정 +updatePopScreenGroup(id: number, data: UpdatePopScreenGroupRequest): Promise<...> + +// 삭제 +deletePopScreenGroup(id: number): Promise<...> + +// 루트 그룹 확보 +ensurePopRootGroup(): Promise<...> +``` + +**트리 변환 유틸리티**: +```typescript +// 플랫 리스트 → 트리 구조 +buildPopGroupTree(groups: PopScreenGroup[]): PopScreenGroup[] +``` + +### `lib/registry/PopComponentRegistry.ts` + +**역할**: POP 컴포넌트 중앙 레지스트리 + +**주요 메서드**: +```typescript +class PopComponentRegistry { + static registerComponent(definition: PopComponentDefinition): void + static unregisterComponent(id: string): void + static getComponent(id: string): PopComponentDefinition | undefined + static getComponentByUrl(url: string): PopComponentDefinition | undefined + static getAllComponents(): PopComponentDefinition[] + static getComponentsByCategory(category: PopComponentCategory): PopComponentDefinition[] + static getComponentsByDevice(device: "mobile" | "tablet"): PopComponentDefinition[] + static searchComponents(query: string): PopComponentDefinition[] +} +``` + +**카테고리**: +```typescript +type PopComponentCategory = + | "display" // 데이터 표시 (카드, 리스트, 배지) + | "input" // 입력 (스캐너, 터치 입력) + | "action" // 액션 (버튼, 스와이프) + | "layout" // 레이아웃 (컨테이너, 그리드) + | "feedback"; // 피드백 (토스트, 로딩) +``` + +### `lib/schemas/popComponentConfig.ts` + +**역할**: POP 컴포넌트 설정 스키마 (Zod 기반) + +**제공 내용**: +- 컴포넌트별 기본값 (`popCardListDefaults`, `popTouchButtonDefaults` 등) +- 컴포넌트별 Zod 스키마 (`popCardListOverridesSchema` 등) +- URL → 기본값/스키마 조회 함수 + +--- + +## 5. 버전별 레이아웃 시스템 + +### v1.0 (deprecated) + +- 단일 모드 +- 섹션 중첩 구조 +- CSS Grid + +### v2.0 (deprecated) + +- 4개 모드 (태블릿/모바일 x 가로/세로) +- 섹션 + 컴포넌트 분리 +- CSS Grid + +### v3.0 (현재 기본) + +- 4개 모드 +- **섹션 제거**, 컴포넌트 직접 배치 +- CSS Grid + +```typescript +interface PopLayoutDataV3 { + version: "pop-3.0"; + layouts: { + tablet_landscape: { componentPositions: Record }; + tablet_portrait: { componentPositions: Record }; + mobile_landscape: { componentPositions: Record }; + mobile_portrait: { componentPositions: Record }; + }; + components: Record; + dataFlow: PopDataFlow; + settings: PopGlobalSettings; +} +``` + +### v4.0 (신규, 권장) + +- **단일 소스** (1번 설계 → 모든 화면 자동 적응) +- **제약 기반** (fixed/fill/hug) +- **Flexbox** 렌더링 +- **반응형 규칙** (breakpoint) + +```typescript +interface PopLayoutDataV4 { + version: "pop-4.0"; + root: PopContainerV4; // 루트 컨테이너 (스택) + components: Record; + dataFlow: PopDataFlow; + settings: PopGlobalSettingsV4; +} + +interface PopContainerV4 { + id: string; + type: "stack"; + direction: "horizontal" | "vertical"; + wrap: boolean; + gap: number; + alignItems: "start" | "center" | "end" | "stretch"; + justifyContent: "start" | "center" | "end" | "space-between"; + padding?: number; + responsive?: PopResponsiveRuleV4[]; // 반응형 규칙 + children: (string | PopContainerV4)[]; // 컴포넌트 ID 또는 중첩 컨테이너 +} + +interface PopSizeConstraintV4 { + width: "fixed" | "fill" | "hug"; + height: "fixed" | "fill" | "hug"; + fixedWidth?: number; + fixedHeight?: number; + minWidth?: number; + maxWidth?: number; + minHeight?: number; +} +``` + +### 버전 비교표 + +| 항목 | v3 | v4 | +|------|----|----| +| 설계 횟수 | 4번 (모드별) | 1번 | +| 위치 지정 | col, row, colSpan, rowSpan | 제약 (fill/fixed/hug) | +| 렌더링 | CSS Grid | Flexbox | +| 반응형 | 수동 (모드 전환) | 자동 (breakpoint 규칙) | +| 복잡도 | 높음 | 낮음 | + +--- + +## 6. 데이터 흐름 + +### 화면 로드 흐름 + +``` +[사용자 접속] + ↓ +[/pop/screens/:screenId] + ↓ +[screenApi.getLayoutPop(screenId)] + ↓ +[레이아웃 버전 감지] + ├── v4 → PopFlexRenderer + ├── v3 → PopLayoutRenderer + └── v1/v2 → ensureV3Layout() → v3로 변환 +``` + +### 디자이너 저장 흐름 + +``` +[사용자 편집] + ↓ +[hasChanges = true] + ↓ +[저장 버튼 클릭] + ↓ +[screenApi.saveLayoutPop(screenId, layoutV3 | layoutV4)] + ↓ +[hasChanges = false] +``` + +### 컴포넌트 드래그 앤 드롭 흐름 + +``` +[PopPanel의 컴포넌트 드래그] + ↓ +[DragItemComponent { type: "component", componentType: "pop-button" }] + ↓ +[캔버스 Drop 감지] + ↓ +[v3: handleDropComponentV3(type, gridPosition)] +[v4: handleDropComponentV4(type, containerId)] + ↓ +[레이아웃 상태 업데이트] + ↓ +[hasChanges = true] +``` + +--- + +## 관련 문서 + +- [PLAN.md](./PLAN.md) - 개발 계획 및 로드맵 +- [components-spec.md](./components-spec.md) - 컴포넌트 상세 스펙 +- [CHANGELOG.md](./CHANGELOG.md) - 변경 이력 + +--- + +*이 문서는 POP 화면 시스템의 구조를 이해하고 유지보수하기 위한 참조용으로 작성되었습니다.* diff --git a/popdocs/CHANGELOG.md b/popdocs/CHANGELOG.md index 5dbd0015..34a1a5a5 100644 --- a/popdocs/CHANGELOG.md +++ b/popdocs/CHANGELOG.md @@ -6,15 +6,155 @@ ## [미출시] -- v4 디자이너 UI 연결 +- Phase 2: 모드별 오버라이드 기능 +- Phase 3: 컴포넌트 표시/숨김 +- Phase 4: 순서 오버라이드 - Tier 2, 3 컴포넌트 --- -## [2026-02-04] +## [2026-02-04] Flexbox 가로 배치 + Spacer + Undo/Redo 개선 -### 오늘 목표 -POP 화면을 만들 수 있는 환경 완성 (타입 + 렌더러 + 기본 컴포넌트) +### Added +- **Spacer 컴포넌트** (`pop-spacer`) + - 빈 공간을 차지하여 레이아웃 정렬에 사용 + - 기본 크기: `width: fill`, `height: 48px` + - 디자인 모드에서 점선 배경으로 표시 + - 실제 모드에서는 투명 (공간만 차지) + +- **컴포넌트 순서 변경 (드래그 앤 드롭)** + - 같은 컨테이너 내에서 컴포넌트 순서 변경 가능 + - 드래그 중인 컴포넌트는 반투명하게 표시 + - 드롭 위치는 파란색 테두리로 표시 + - `handleReorderComponentV4` 핸들러 추가 + +### Changed +- **기본 레이아웃 방향 변경** (Flexbox 가로 배치) + - `direction: "vertical"` → `direction: "horizontal"` + - `wrap: false` → `wrap: true` (자동 줄바꿈) + - `alignItems: "stretch"` → `alignItems: "start"` + - 컴포넌트가 가로로 나열되고, 공간 부족 시 다음 줄로 이동 + +- **컴포넌트 기본 크기 타입별 설정** + - 필드: 200x48px (fixed) + - 버튼: 120x48px (fixed) + - 리스트: fill x 200px + - 인디케이터: 120x80px (fixed) + - 스캐너: 200x48px (fixed) + - 숫자패드: 200x280px (fixed) + - Spacer: fill x 48px + +- **Undo/Redo 방식 개선** (데스크탑 모드와 동일) + - `useLayoutHistory` 훅 제거 + - 별도 `history[]`, `historyIndex` 상태로 관리 + - `saveToHistoryV4()` 함수로 명시적 히스토리 저장 + - 컴포넌트 추가/삭제/수정/순서변경 시 히스토리 저장 + +- **디바이스 스크린 스크롤** + - `overflow: auto` 추가 (컴포넌트가 넘치면 스크롤) + - `height` → `minHeight` 변경 (컨텐츠에 따라 높이 증가) + +### Technical Details +``` +업계 표준 레이아웃 방식 (Figma, Webflow, FlutterFlow): +1. Flexbox 기반 Row/Column 배치 +2. 크기 제어: Fill / Fixed / Hug +3. Spacer 컴포넌트로 정렬 조정 +4. 화면 크기별 조건 분기 (반응형) + +사용 예시: +[버튼A] [Spacer(fill)] [버튼B] → 버튼B가 오른쪽 끝으로 +[Spacer] [컴포넌트] [Spacer] → 컴포넌트가 가운데로 +``` + +--- + +## [2026-02-04] 드래그 리사이즈 + Undo/Redo 기능 + +### Added +- **useLayoutHistory.ts** - Undo/Redo 히스토리 훅 + - 최대 50개 히스토리 저장 + - `undo()`, `redo()`, `canUndo`, `canRedo` + - `reset()` - 새 레이아웃 로드 시 히스토리 초기화 + +- **드래그 리사이즈 핸들** (PopFlexRenderer) + - 오른쪽 핸들: 너비 조정 (cursor: ew-resize) + - 아래쪽 핸들: 높이 조정 (cursor: ns-resize) + - 오른쪽 아래 핸들: 너비+높이 동시 조정 (cursor: nwse-resize) + - 선택된 컴포넌트에만 표시 + - 최소 크기 보장 (너비 48px, 높이 touchTargetMin) + +### Changed +- **PopDesigner.tsx** + - `useLayoutHistory` 훅 통합 (v3, v4 각각 독립적) + - Undo/Redo 버튼 추가 (툴바 오른쪽) + - 단축키 등록: + - `Ctrl+Z` / `Cmd+Z`: 실행 취소 + - `Ctrl+Shift+Z` / `Cmd+Shift+Z` / `Ctrl+Y`: 다시 실행 + - `handleResizeComponentV4` 핸들러 추가 + +- **PopCanvasV4.tsx** + - `onResizeComponent` prop 추가 + - PopFlexRenderer에 전달 + +- **PopFlexRenderer.tsx** + - `onComponentResize` prop 추가 + - ComponentRendererV4에 리사이즈 핸들 추가 + - 드래그 이벤트 처리 (mousemove, mouseup) + +### 단축키 목록 +| 단축키 | 기능 | +|--------|------| +| `Delete` / `Backspace` | 선택된 컴포넌트 삭제 | +| `Ctrl+Z` / `Cmd+Z` | 실행 취소 (Undo) | +| `Ctrl+Shift+Z` / `Ctrl+Y` | 다시 실행 (Redo) | +| `Space` + 드래그 | 캔버스 패닝 | +| `Ctrl` + 휠 | 줌 인/아웃 | + +--- + +## [2026-02-04] v4 통합 설계 모드 Phase 1 완료 + +### 목표 +v4를 기본 레이아웃 모드로 통합하고, 새 화면은 자동으로 v4로 시작 + +### Added +- **ComponentPaletteV4.tsx** - v4 전용 컴포넌트 팔레트 + - 6개 컴포넌트 (필드, 버튼, 리스트, 인디케이터, 스캐너, 숫자패드) + - 드래그 앤 드롭 지원 + +### Changed +- **PopDesigner.tsx** - v3/v4 통합 디자이너로 리팩토링 + - v3/v4 탭 제거 (자동 판별) + - 새 화면 → v4로 시작 + - 기존 v3 화면 → v3로 로드 (하위 호환) + - 빈 레이아웃 → v4로 시작 (컴포넌트 유무로 판별) + - 레이아웃 버전 텍스트 표시 ("자동 레이아웃 (v4)" / "4모드 레이아웃 (v3)") + +- **PopCanvasV4.tsx** - 4개 프리셋으로 변경 + - 기존: [모바일] [태블릿] [데스크톱] + - 변경: [모바일↕] [모바일↔] [태블릿↕] [태블릿↔] + - 기본 프리셋: 태블릿 가로 (1024x768) + - 슬라이더 범위: 320~1200px + - 비율 유지: 슬라이더 조절 시 높이도 비율에 맞게 자동 조정 + +### Fixed +- 새 화면이 v3로 열리는 문제 + - 원인: 백엔드가 빈 v2 레이아웃 반환 (version 필드 있음) + - 해결: 컴포넌트 유무로 빈 레이아웃 판별 → v4로 시작 + +### Technical Details +``` +레이아웃 로드 로직: +1. version 필드 확인 +2. components 존재 여부 확인 +3. version 있고 components 있음 → 해당 버전으로 로드 +4. version 없거나 components 없음 → v4로 새로 시작 +``` + +--- + +## [2026-02-04] v4 타입 및 렌더러 ### Added - **v4 타입 정의** (간결 버전) @@ -26,10 +166,21 @@ POP 화면을 만들 수 있는 환경 완성 (타입 + 렌더러 + 기본 컴 - `PopGlobalSettingsV4` - 전역 설정 - `createEmptyPopLayoutV4()` - 생성 함수 - `isV4Layout()` - 타입 가드 + - CRUD 함수들 (add, remove, update, find) -### 진행중 -- v4 렌더러 (`PopFlexRenderer`) -- 기본 컴포넌트 (PopButton, PopInput, PopLabel) +- **PopFlexRenderer.tsx** - v4 Flexbox 렌더러 + - 컨테이너 재귀 렌더링 + - 반응형 규칙 적용 + - 크기 제약 → CSS 변환 + +- **ComponentEditorPanelV4.tsx** - v4 속성 편집 패널 + - 크기 제약 편집 UI + - 컨테이너 설정 UI + +- **PopCanvasV4.tsx** - v4 전용 캔버스 + - 뷰포트 프리셋 + - 너비 슬라이더 + - 줌/패닝 --- diff --git a/popdocs/FILES.md b/popdocs/FILES.md new file mode 100644 index 00000000..f9e3f8ce --- /dev/null +++ b/popdocs/FILES.md @@ -0,0 +1,925 @@ +# POP 파일 상세 목록 + +**최종 업데이트: 2026-02-04** + +이 문서는 POP 화면 시스템과 관련된 모든 파일을 나열하고 각 파일의 역할을 설명합니다. + +--- + +## 목차 + +1. [App Router 파일](#1-app-router-파일) +2. [Designer 파일](#2-designer-파일) +3. [Panels 파일](#3-panels-파일) +4. [Renderers 파일](#4-renderers-파일) +5. [Types 파일](#5-types-파일) +6. [Management 파일](#6-management-파일) +7. [Dashboard 파일](#7-dashboard-파일) +8. [Library 파일](#8-library-파일) +9. [루트 컴포넌트 파일](#9-루트-컴포넌트-파일) + +--- + +## 1. App Router 파일 + +### `frontend/app/(pop)/layout.tsx` + +| 항목 | 내용 | +|------|------| +| 역할 | POP 전용 레이아웃 래퍼 | +| 라우트 그룹 | `(pop)` - URL에 포함되지 않음 | +| 특징 | 데스크톱과 분리된 터치 최적화 환경 | + +--- + +### `frontend/app/(pop)/pop/page.tsx` + +| 항목 | 내용 | +|------|------| +| 역할 | POP 메인 대시보드 | +| 경로 | `/pop` | +| 사용 컴포넌트 | `PopDashboard` | + +--- + +### `frontend/app/(pop)/pop/screens/[screenId]/page.tsx` + +| 항목 | 내용 | +|------|------| +| 역할 | 개별 POP 화면 뷰어 | +| 경로 | `/pop/screens/:screenId` | +| 라인 수 | 468줄 | + +**핵심 코드 구조**: + +```typescript +// 상태 +const [popLayoutV3, setPopLayoutV3] = useState(null); +const [popLayoutV4, setPopLayoutV4] = useState(null); + +// 레이아웃 로드 +useEffect(() => { + const popLayout = await screenApi.getLayoutPop(screenId); + + if (isPopLayoutV4(popLayout)) { + setPopLayoutV4(popLayout); + } else if (isPopLayout(popLayout)) { + const v3Layout = ensureV3Layout(popLayout); + setPopLayoutV3(v3Layout); + } +}, [screenId]); + +// 렌더링 분기 +{popLayoutV4 ? ( + +) : popLayoutV3 ? ( + +) : ( + // 빈 화면 +)} +``` + +**제공 기능**: +- 반응형 모드 감지 (useResponsiveModeWithOverride) +- 프리뷰 모드 (`?preview=true`) +- 디바이스/방향 수동 전환 (프리뷰 모드) +- v1/v2/v3/v4 레이아웃 자동 감지 + +--- + +### `frontend/app/(pop)/pop/test-v4/page.tsx` + +| 항목 | 내용 | +|------|------| +| 역할 | v4 렌더러 테스트 페이지 | +| 경로 | `/pop/test-v4` | +| 라인 수 | 150줄 | + +**핵심 코드 구조**: + +```typescript +export default function TestV4Page() { + const [layout, setLayout] = useState(createEmptyPopLayoutV4()); + const [selectedComponentId, setSelectedComponentId] = useState(null); + const [selectedContainerId, setSelectedContainerId] = useState(null); + const [idCounter, setIdCounter] = useState(1); + + // 컴포넌트 CRUD + const handleDropComponent = useCallback(...); + const handleDeleteComponent = useCallback(...); + const handleUpdateComponent = useCallback(...); + const handleUpdateContainer = useCallback(...); + + return ( + + {/* 3-column 레이아웃 */} + + + + + ); +} +``` + +--- + +### `frontend/app/(pop)/pop/work/page.tsx` + +| 항목 | 내용 | +|------|------| +| 역할 | 작업 화면 (샘플) | +| 경로 | `/pop/work` | + +--- + +## 2. Designer 파일 + +### `frontend/components/pop/designer/PopDesigner.tsx` + +| 항목 | 내용 | +|------|------| +| 역할 | POP 화면 디자이너 메인 | +| 라인 수 | 524줄 | +| 의존성 | react-dnd, ResizablePanelGroup | + +**핵심 Props**: + +```typescript +interface PopDesignerProps { + selectedScreen: ScreenDefinition; + onBackToList: () => void; + onScreenUpdate?: (updatedScreen: Partial) => void; +} +``` + +**상태 관리**: + +```typescript +// 레이아웃 모드 +const [layoutMode, setLayoutMode] = useState<"v3" | "v4">("v3"); + +// v3 레이아웃 +const [layoutV3, setLayoutV3] = useState(createEmptyPopLayoutV3()); + +// v4 레이아웃 +const [layoutV4, setLayoutV4] = useState(createEmptyPopLayoutV4()); + +// 선택 상태 +const [selectedComponentId, setSelectedComponentId] = useState(null); +const [selectedContainerId, setSelectedContainerId] = useState(null); + +// UI 상태 +const [isLoading, setIsLoading] = useState(true); +const [isSaving, setIsSaving] = useState(false); +const [hasChanges, setHasChanges] = useState(false); + +// v3용 상태 +const [activeDevice, setActiveDevice] = useState("tablet"); +const [activeModeKey, setActiveModeKey] = useState("tablet_landscape"); +``` + +**주요 핸들러**: + +| 핸들러 | 역할 | +|--------|------| +| `handleDropComponentV3` | v3 컴포넌트 드롭 | +| `handleDropComponentV4` | v4 컴포넌트 드롭 | +| `handleUpdateComponentDefinitionV3` | v3 컴포넌트 정의 수정 | +| `handleUpdateComponentV4` | v4 컴포넌트 수정 | +| `handleUpdateContainerV4` | v4 컨테이너 수정 | +| `handleDeleteComponentV3` | v3 컴포넌트 삭제 | +| `handleDeleteComponentV4` | v4 컴포넌트 삭제 | +| `handleSave` | 레이아웃 저장 | + +--- + +### `frontend/components/pop/designer/PopCanvas.tsx` + +| 항목 | 내용 | +|------|------| +| 역할 | v3 CSS Grid 기반 캔버스 | +| 렌더링 | CSS Grid | +| 모드 | 4개 (태블릿/모바일 x 가로/세로) | + +--- + +### `frontend/components/pop/designer/PopCanvasV4.tsx` + +| 항목 | 내용 | +|------|------| +| 역할 | v4 Flexbox 기반 캔버스 | +| 라인 수 | 309줄 | +| 렌더링 | Flexbox (via PopFlexRenderer) | + +**핵심 Props**: + +```typescript +interface PopCanvasV4Props { + layout: PopLayoutDataV4; + selectedComponentId: string | null; + selectedContainerId: string | null; + onSelectComponent: (id: string | null) => void; + onSelectContainer: (id: string | null) => void; + onDropComponent: (type: PopComponentType, containerId: string) => void; + onUpdateComponent: (componentId: string, updates: Partial) => void; + onUpdateContainer: (containerId: string, updates: Partial) => void; + onDeleteComponent: (componentId: string) => void; +} +``` + +**뷰포트 프리셋**: + +```typescript +const VIEWPORT_PRESETS = [ + { id: "mobile", label: "모바일", width: 375, height: 667, icon: Smartphone }, + { id: "tablet", label: "태블릿", width: 768, height: 1024, icon: Tablet }, + { id: "desktop", label: "데스크톱", width: 1024, height: 768, icon: Monitor }, +]; +``` + +**제공 기능**: +- 뷰포트 프리셋 전환 +- 너비 슬라이더 (320px ~ 1440px) +- 줌 컨트롤 (30% ~ 150%) +- 패닝 (Space + 드래그 또는 휠 클릭) +- 컴포넌트 드롭 + +--- + +### `frontend/components/pop/designer/index.ts` + +```typescript +export { default as PopDesigner } from "./PopDesigner"; +export { PopCanvas } from "./PopCanvas"; +export { PopCanvasV4 } from "./PopCanvasV4"; +export * from "./panels"; +export * from "./renderers"; +export * from "./types"; +``` + +--- + +## 3. Panels 파일 + +### `frontend/components/pop/designer/panels/PopPanel.tsx` + +| 항목 | 내용 | +|------|------| +| 역할 | 왼쪽 패널 (컴포넌트 팔레트 + 편집) | +| 라인 수 | 369줄 | + +**탭 구성**: +1. `components` - 컴포넌트 팔레트 +2. `edit` - 선택된 컴포넌트 편집 + +**컴포넌트 팔레트**: + +```typescript +const COMPONENT_PALETTE = [ + { type: "pop-field", label: "필드", icon: Type, description: "텍스트, 숫자 등 데이터 입력" }, + { type: "pop-button", label: "버튼", icon: MousePointer, description: "저장, 삭제 등 액션 실행" }, + { type: "pop-list", label: "리스트", icon: List, description: "데이터 목록 (카드 템플릿 지원)" }, + { type: "pop-indicator", label: "인디케이터", icon: Activity, description: "KPI, 상태 표시" }, + { type: "pop-scanner", label: "스캐너", icon: ScanLine, description: "바코드/QR 스캔" }, + { type: "pop-numpad", label: "숫자패드", icon: Calculator, description: "숫자 입력 전용" }, +]; +``` + +**내보내기 (exports)**: + +```typescript +export const DND_ITEM_TYPES = { COMPONENT: "component" }; +export interface DragItemComponent { ... } +export function PopPanel({ ... }: PopPanelProps) { ... } +``` + +--- + +### `frontend/components/pop/designer/panels/ComponentEditorPanel.tsx` + +| 항목 | 내용 | +|------|------| +| 역할 | v3 컴포넌트 편집 패널 | +| 용도 | PopPanel 내부에서 사용 | + +--- + +### `frontend/components/pop/designer/panels/ComponentEditorPanelV4.tsx` + +| 항목 | 내용 | +|------|------| +| 역할 | v4 오른쪽 속성 패널 | +| 라인 수 | 609줄 | + +**핵심 Props**: + +```typescript +interface ComponentEditorPanelV4Props { + component: PopComponentDefinitionV4 | null; + container: PopContainerV4 | null; + onUpdateComponent?: (updates: Partial) => void; + onUpdateContainer?: (updates: Partial) => void; + className?: string; +} +``` + +**3개 탭**: + +| 탭 | 아이콘 | 내용 | +|----|--------|------| +| `size` | Maximize2 | 크기 제약 (fixed/fill/hug) | +| `settings` | Settings | 라벨, 타입별 설정 | +| `data` | Database | 데이터 바인딩 (미구현) | + +**내부 컴포넌트**: + +| 컴포넌트 | 역할 | +|----------|------| +| `SizeConstraintForm` | 너비/높이 제약 편집 | +| `SizeButton` | fixed/fill/hug 선택 버튼 | +| `ContainerSettingsForm` | 컨테이너 방향/정렬/간격 편집 | +| `ComponentSettingsForm` | 라벨 편집 | +| `DataBindingPlaceholder` | 데이터 바인딩 플레이스홀더 | + +--- + +### `frontend/components/pop/designer/panels/index.ts` + +```typescript +export { PopPanel, DND_ITEM_TYPES } from "./PopPanel"; +export type { DragItemComponent } from "./PopPanel"; +export { ComponentEditorPanel } from "./ComponentEditorPanel"; +export { ComponentEditorPanelV4 } from "./ComponentEditorPanelV4"; +``` + +--- + +## 4. Renderers 파일 + +### `frontend/components/pop/designer/renderers/PopLayoutRenderer.tsx` + +| 항목 | 내용 | +|------|------| +| 역할 | v3 레이아웃 CSS Grid 렌더러 | +| 입력 | PopLayoutDataV3, modeKey | + +**내보내기**: + +```typescript +export function PopLayoutRenderer({ ... }) { ... } +export function hasBaseLayout(layout: PopLayoutDataV3): boolean { ... } +export function getEffectiveModeLayout(layout: PopLayoutDataV3, modeKey: PopLayoutModeKey) { ... } +``` + +--- + +### `frontend/components/pop/designer/renderers/PopFlexRenderer.tsx` + +| 항목 | 내용 | +|------|------| +| 역할 | v4 레이아웃 Flexbox 렌더러 | +| 라인 수 | 498줄 | +| 입력 | PopLayoutDataV4, viewportWidth | + +**핵심 Props**: + +```typescript +interface PopFlexRendererProps { + layout: PopLayoutDataV4; + viewportWidth: number; + isDesignMode?: boolean; + selectedComponentId?: string | null; + onComponentClick?: (componentId: string) => void; + onContainerClick?: (containerId: string) => void; + onBackgroundClick?: () => void; + className?: string; +} +``` + +**내부 컴포넌트**: + +| 컴포넌트 | 역할 | +|----------|------| +| `ContainerRenderer` | 컨테이너 재귀 렌더링 | +| `ComponentRendererV4` | v4 컴포넌트 렌더링 | + +**핵심 함수**: + +```typescript +// 반응형 규칙 적용 +function applyResponsiveRules(container: PopContainerV4, viewportWidth: number): PopContainerV4 + +// 크기 제약 → CSS 스타일 +function calculateSizeStyle(size: PopSizeConstraintV4, settings: PopGlobalSettingsV4): React.CSSProperties + +// 정렬 값 변환 +function mapAlignment(value: string): React.CSSProperties["alignItems"] +function mapJustify(value: string): React.CSSProperties["justifyContent"] + +// 컴포넌트 내용 렌더링 +function renderComponentContent(component: PopComponentDefinitionV4, ...): React.ReactNode +``` + +--- + +### `frontend/components/pop/designer/renderers/ComponentRenderer.tsx` + +| 항목 | 내용 | +|------|------| +| 역할 | 개별 컴포넌트 렌더러 (디자인 모드용) | + +--- + +### `frontend/components/pop/designer/renderers/index.ts` + +```typescript +export { PopLayoutRenderer, hasBaseLayout, getEffectiveModeLayout } from "./PopLayoutRenderer"; +export { ComponentRenderer } from "./ComponentRenderer"; +export { PopFlexRenderer } from "./PopFlexRenderer"; +``` + +--- + +## 5. Types 파일 + +### `frontend/components/pop/designer/types/pop-layout.ts` + +| 항목 | 내용 | +|------|------| +| 역할 | POP 레이아웃 전체 타입 시스템 | +| 라인 수 | 1442줄 | + +**주요 타입** (v4): + +```typescript +// v4 레이아웃 +interface PopLayoutDataV4 { + version: "pop-4.0"; + root: PopContainerV4; + components: Record; + dataFlow: PopDataFlow; + settings: PopGlobalSettingsV4; + metadata?: PopLayoutMetadata; +} + +// v4 컨테이너 +interface PopContainerV4 { + id: string; + type: "stack"; + direction: "horizontal" | "vertical"; + wrap: boolean; + gap: number; + alignItems: "start" | "center" | "end" | "stretch"; + justifyContent: "start" | "center" | "end" | "space-between"; + padding?: number; + responsive?: PopResponsiveRuleV4[]; + children: (string | PopContainerV4)[]; +} + +// v4 크기 제약 +interface PopSizeConstraintV4 { + width: "fixed" | "fill" | "hug"; + height: "fixed" | "fill" | "hug"; + fixedWidth?: number; + fixedHeight?: number; + minWidth?: number; + maxWidth?: number; + minHeight?: number; +} + +// v4 반응형 규칙 +interface PopResponsiveRuleV4 { + breakpoint: number; + direction?: "horizontal" | "vertical"; + gap?: number; + hidden?: boolean; +} +``` + +**주요 타입** (v3): + +```typescript +// v3 레이아웃 +interface PopLayoutDataV3 { + version: "pop-3.0"; + layouts: { + tablet_landscape: PopModeLayoutV3; + tablet_portrait: PopModeLayoutV3; + mobile_landscape: PopModeLayoutV3; + mobile_portrait: PopModeLayoutV3; + }; + components: Record; + dataFlow: PopDataFlow; + settings: PopGlobalSettings; + metadata?: PopLayoutMetadata; +} + +// v3 모드별 레이아웃 +interface PopModeLayoutV3 { + componentPositions: Record; +} + +// 그리드 위치 +interface GridPosition { + col: number; + row: number; + colSpan: number; + rowSpan: number; +} +``` + +**주요 함수**: + +| 함수 | 역할 | +|------|------| +| `createEmptyPopLayoutV4()` | 빈 v4 레이아웃 생성 | +| `createEmptyPopLayoutV3()` | 빈 v3 레이아웃 생성 | +| `addComponentToV4Layout()` | v4에 컴포넌트 추가 | +| `removeComponentFromV4Layout()` | v4에서 컴포넌트 삭제 | +| `updateComponentInV4Layout()` | v4 컴포넌트 수정 | +| `updateContainerV4()` | v4 컨테이너 수정 | +| `findContainerV4()` | v4 컨테이너 찾기 | +| `addComponentToV3Layout()` | v3에 컴포넌트 추가 | +| `removeComponentFromV3Layout()` | v3에서 컴포넌트 삭제 | +| `updateComponentPositionInModeV3()` | v3 특정 모드 위치 수정 | +| `isV4Layout()` | v4 타입 가드 | +| `isV3Layout()` | v3 타입 가드 | +| `ensureV3Layout()` | v1/v2/v3 → v3 변환 | +| `migrateV2ToV3()` | v2 → v3 마이그레이션 | +| `migrateV1ToV3()` | v1 → v3 마이그레이션 | + +--- + +### `frontend/components/pop/designer/types/index.ts` + +```typescript +export * from "./pop-layout"; +``` + +--- + +## 6. Management 파일 + +### `frontend/components/pop/management/PopCategoryTree.tsx` + +| 항목 | 내용 | +|------|------| +| 역할 | POP 화면 카테고리 트리 | +| 기능 | 그룹 추가/수정/삭제, 화면 목록 | + +--- + +### `frontend/components/pop/management/PopScreenSettingModal.tsx` + +| 항목 | 내용 | +|------|------| +| 역할 | POP 화면 설정 모달 | +| 기능 | 화면명, 설명, 그룹 설정 | + +--- + +### `frontend/components/pop/management/PopScreenPreview.tsx` + +| 항목 | 내용 | +|------|------| +| 역할 | POP 화면 미리보기 | +| 기능 | 썸네일, 기본 정보 표시 | + +--- + +### `frontend/components/pop/management/PopScreenFlowView.tsx` + +| 항목 | 내용 | +|------|------| +| 역할 | 화면 간 플로우 시각화 | +| 기능 | 화면 연결 관계 표시 | + +--- + +### `frontend/components/pop/management/index.ts` + +```typescript +export { PopCategoryTree } from "./PopCategoryTree"; +export { PopScreenSettingModal } from "./PopScreenSettingModal"; +export { PopScreenPreview } from "./PopScreenPreview"; +export { PopScreenFlowView } from "./PopScreenFlowView"; +``` + +--- + +## 7. Dashboard 파일 + +### `frontend/components/pop/dashboard/PopDashboard.tsx` + +| 항목 | 내용 | +|------|------| +| 역할 | POP 대시보드 메인 | +| 구성 | 헤더, KPI, 메뉴그리드, 공지, 푸터 | + +--- + +### `frontend/components/pop/dashboard/DashboardHeader.tsx` + +| 항목 | 내용 | +|------|------| +| 역할 | 상단 헤더 | +| 표시 | 로고, 시간, 사용자 정보 | + +--- + +### `frontend/components/pop/dashboard/DashboardFooter.tsx` + +| 항목 | 내용 | +|------|------| +| 역할 | 하단 푸터 | + +--- + +### `frontend/components/pop/dashboard/MenuGrid.tsx` + +| 항목 | 내용 | +|------|------| +| 역할 | 메뉴 그리드 | +| 스타일 | 앱 아이콘 형태 | + +--- + +### `frontend/components/pop/dashboard/KpiBar.tsx` + +| 항목 | 내용 | +|------|------| +| 역할 | KPI 요약 바 | +| 표시 | 핵심 지표 수치 | + +--- + +### `frontend/components/pop/dashboard/NoticeBanner.tsx` + +| 항목 | 내용 | +|------|------| +| 역할 | 공지 배너 | +| 스타일 | 슬라이드 배너 | + +--- + +### `frontend/components/pop/dashboard/NoticeList.tsx` + +| 항목 | 내용 | +|------|------| +| 역할 | 공지 목록 | +| 스타일 | 리스트 형태 | + +--- + +### `frontend/components/pop/dashboard/ActivityList.tsx` + +| 항목 | 내용 | +|------|------| +| 역할 | 최근 활동 목록 | + +--- + +### `frontend/components/pop/dashboard/index.ts` + +```typescript +export { PopDashboard } from "./PopDashboard"; +export { DashboardHeader } from "./DashboardHeader"; +export { DashboardFooter } from "./DashboardFooter"; +export { MenuGrid } from "./MenuGrid"; +export { KpiBar } from "./KpiBar"; +export { NoticeBanner } from "./NoticeBanner"; +export { NoticeList } from "./NoticeList"; +export { ActivityList } from "./ActivityList"; +``` + +--- + +### `frontend/components/pop/dashboard/types.ts` + +대시보드 관련 타입 정의 + +--- + +### `frontend/components/pop/dashboard/data.ts` + +대시보드 샘플/목업 데이터 + +--- + +### `frontend/components/pop/dashboard/dashboard.css` + +대시보드 전용 스타일 + +--- + +## 8. Library 파일 + +### `frontend/lib/api/popScreenGroup.ts` + +| 항목 | 내용 | +|------|------| +| 역할 | POP 화면 그룹 API 클라이언트 | +| 라인 수 | 183줄 | + +**타입**: + +```typescript +interface PopScreenGroup extends ScreenGroup { + children?: PopScreenGroup[]; +} + +interface CreatePopScreenGroupRequest { + group_name: string; + group_code: string; + description?: string; + icon?: string; + display_order?: number; + parent_group_id?: number | null; + target_company_code?: string; +} + +interface UpdatePopScreenGroupRequest { + group_name?: string; + description?: string; + icon?: string; + display_order?: number; + is_active?: boolean; +} +``` + +**API 함수**: + +```typescript +async function getPopScreenGroups(searchTerm?: string): Promise +async function createPopScreenGroup(data: CreatePopScreenGroupRequest): Promise<...> +async function updatePopScreenGroup(id: number, data: UpdatePopScreenGroupRequest): Promise<...> +async function deletePopScreenGroup(id: number): Promise<...> +async function ensurePopRootGroup(): Promise<...> +``` + +**유틸리티**: + +```typescript +function buildPopGroupTree(groups: PopScreenGroup[]): PopScreenGroup[] +``` + +--- + +### `frontend/lib/registry/PopComponentRegistry.ts` + +| 항목 | 내용 | +|------|------| +| 역할 | POP 컴포넌트 중앙 레지스트리 | +| 라인 수 | 268줄 | + +**타입**: + +```typescript +interface PopComponentDefinition { + id: string; + name: string; + description: string; + category: PopComponentCategory; + icon?: string; + component: React.ComponentType; + configPanel?: React.ComponentType; + defaultProps?: Record; + touchOptimized?: boolean; + minTouchArea?: number; + supportedDevices?: ("mobile" | "tablet")[]; + createdAt?: Date; + updatedAt?: Date; +} + +type PopComponentCategory = + | "display" + | "input" + | "action" + | "layout" + | "feedback"; +``` + +**메서드**: + +```typescript +class PopComponentRegistry { + static registerComponent(definition: PopComponentDefinition): void + static unregisterComponent(id: string): void + static getComponent(id: string): PopComponentDefinition | undefined + static getComponentByUrl(url: string): PopComponentDefinition | undefined + static getAllComponents(): PopComponentDefinition[] + static getComponentsByCategory(category: PopComponentCategory): PopComponentDefinition[] + static getComponentsByDevice(device: "mobile" | "tablet"): PopComponentDefinition[] + static searchComponents(query: string): PopComponentDefinition[] + static getComponentCount(): number + static getStatsByCategory(): Record + static addEventListener(callback: (event: PopComponentRegistryEvent) => void): void + static removeEventListener(callback: (event: PopComponentRegistryEvent) => void): void + static clear(): void + static hasComponent(id: string): boolean + static debug(): void +} +``` + +--- + +### `frontend/lib/schemas/popComponentConfig.ts` + +| 항목 | 내용 | +|------|------| +| 역할 | POP 컴포넌트 설정 스키마 | +| 라인 수 | 232줄 | +| 검증 | Zod 기반 | + +**기본값**: + +```typescript +const popCardListDefaults = { ... } +const popTouchButtonDefaults = { ... } +const popScannerInputDefaults = { ... } +const popStatusBadgeDefaults = { ... } +``` + +**스키마**: + +```typescript +const popCardListOverridesSchema = z.object({ ... }) +const popTouchButtonOverridesSchema = z.object({ ... }) +const popScannerInputOverridesSchema = z.object({ ... }) +const popStatusBadgeOverridesSchema = z.object({ ... }) +``` + +**유틸리티**: + +```typescript +function getPopComponentUrl(componentType: string): string +function getPopComponentDefaults(componentType: string): Record +function getPopDefaultsByUrl(componentUrl: string): Record +function parsePopOverridesByUrl(componentUrl: string, overrides: Record): Record +``` + +--- + +## 9. 루트 컴포넌트 파일 + +### `frontend/components/pop/index.ts` + +```typescript +export * from "./designer"; +export * from "./management"; +export * from "./dashboard"; +// 개별 컴포넌트 export +``` + +--- + +### `frontend/components/pop/types.ts` + +POP 공통 타입 정의 + +--- + +### `frontend/components/pop/data.ts` + +POP 샘플/목업 데이터 + +--- + +### `frontend/components/pop/styles.css` + +POP 전역 스타일 + +--- + +### 기타 루트 레벨 컴포넌트 + +| 파일 | 역할 | +|------|------| +| `PopApp.tsx` | POP 앱 셸 | +| `PopHeader.tsx` | 공통 헤더 | +| `PopBottomNav.tsx` | 하단 네비게이션 | +| `PopStatusTabs.tsx` | 상태 탭 | +| `PopWorkCard.tsx` | 작업 카드 | +| `PopProductionPanel.tsx` | 생산 패널 | +| `PopSettingsModal.tsx` | 설정 모달 | +| `PopAcceptModal.tsx` | 수락 모달 | +| `PopProcessModal.tsx` | 프로세스 모달 | +| `PopEquipmentModal.tsx` | 설비 모달 | + +--- + +## 파일 수 통계 + +| 폴더 | 파일 수 | 설명 | +|------|---------|------| +| `app/(pop)` | 6 | App Router 페이지 | +| `components/pop/designer` | 12 | 디자이너 모듈 | +| `components/pop/management` | 5 | 관리 모듈 | +| `components/pop/dashboard` | 12 | 대시보드 모듈 | +| `components/pop` (루트) | 15 | 루트 컴포넌트 | +| `lib` | 3 | 라이브러리 | +| **총계** | **53** | | + +--- + +*이 문서는 POP 화면 시스템의 파일 목록을 관리하기 위한 참조용으로 작성되었습니다.* diff --git a/popdocs/PLAN.md b/popdocs/PLAN.md index 8af402a8..5a730079 100644 --- a/popdocs/PLAN.md +++ b/popdocs/PLAN.md @@ -2,29 +2,102 @@ --- -## 오늘 목표 (2026-02-04) +## 현재 상태 (2026-02-04) -**POP 화면을 만들 수 있는 환경 완성** +**v4 통합 설계 모드 Phase 1.5 완료 (Flexbox 가로 배치 + Spacer)** -### 필요한 것 +### 완료된 작업 -1. **v4 타입 정의** - 완료 -2. **v4 렌더러** - Flexbox로 화면에 표시 -3. **기본 컴포넌트** - 실제 배치할 요소들 -4. **디자이너 UI 연결** - v4 모드로 설계 가능 +1. **v4 기본 구조** - 완료 +2. **v4 렌더러** - 완료 (PopFlexRenderer) +3. **v4 디자이너 통합** - 완료 +4. **새 화면 v4 기본 적용** - 완료 +5. **Undo/Redo** - 완료 (데스크탑 모드와 동일 방식) +6. **드래그 리사이즈** - 완료 +7. **Flexbox 가로 배치** - 완료 (업계 표준 방식) +8. **Spacer 컴포넌트** - 완료 +9. **컴포넌트 순서 변경** - 완료 (드래그 앤 드롭) -### 작업 순서 +### 현재 UI 상태 ``` -[v4 타입] → [렌더러] → [컴포넌트] → [디자이너 연결] - 완료 진행 대기 대기 +┌─────────────────────────────────────────────────────────────────┐ +│ ← 목록 화면명 *변경됨 [↶][↷] 자동 레이아웃 (v4) [저장] │ +├─────────────────────────────────────────────────────────────────┤ +│ 미리보기: [모바일↕] [모바일↔] [태블릿↕] [태블릿↔(기본)] │ +│ 너비: [========●====] 1024 x 768 70% [-][+] │ +├─────────────────────────────────────────────────────────────────┤ +│ │ │ │ +│ 컴포넌트 │ [필드1] [필드2] [필드3] [필드4] │ 속성 패널 │ +│ 팔레트 │ [필드5] [Spacer] [Spacer] │ │ +│ (20%) │ (가로 배치 + 줄바꿈) │ (20%) │ +│ │ │ │ +│ - 필드 │ 디바이스 스크린 (스크롤 가능) │ │ +│ - 버튼 │ │ │ +│ - 리스트 │ │ │ +│ - 인디케이터│ │ │ +│ - 스캐너 │ │ │ +│ - 숫자패드 │ │ │ +│ - 스페이서 │ │ │ +└──────────┴────────────────────────────────────┴─────────────────┘ ``` --- -## 현재 진행 +## 작업 순서 -### v4 타입 정의 (완료) +``` +[Phase 1] [Phase 2] [Phase 3] [Phase 4] +v4 기본 구조 → 오버라이드 기능 → 컴포넌트 숨김 → 순서 오버라이드 + 완료 다음 계획 계획 +``` + +--- + +## Phase 1: 기본 구조 (완료) + +- [x] v3/v4 탭 제거 (자동 판별) +- [x] 새 화면 → v4로 시작 +- [x] 기존 v3 화면 → v3로 로드 (하위 호환) +- [x] 4개 프리셋 버튼 (모바일↕, 모바일↔, 태블릿↕, 태블릿↔) +- [x] 슬라이더 유지 (320~1200px) +- [x] 기본 프리셋: 태블릿 가로 (1024x768) +- [x] ComponentPaletteV4 생성 (v4 전용 팔레트) +- [x] 빈 레이아웃도 v4로 시작하도록 로직 수정 + +## Phase 1.5: Flexbox 가로 배치 + 기본 기능 (완료) + +- [x] Undo/Redo (Ctrl+Z / Ctrl+Shift+Z) +- [x] 드래그 리사이즈 핸들 (오른쪽, 아래, 오른쪽아래) +- [x] Flexbox 가로 배치 (`direction: horizontal`, `wrap: true`) +- [x] 컴포넌트 타입별 기본 크기 설정 +- [x] Spacer 컴포넌트 (`pop-spacer`) - 정렬용 빈 공간 +- [x] 컴포넌트 순서 변경 (드래그 앤 드롭) +- [x] 디바이스 스크린 스크롤 (무한 스크롤) + +## Phase 2: 오버라이드 기능 (다음) + +- [ ] ModeOverride 데이터 구조 추가 +- [ ] 편집 감지 → 자동 오버라이드 저장 +- [ ] 편집 상태 표시 (버튼 색상 변경) +- [ ] "자동으로 되돌리기" 버튼 + +## Phase 3: 컴포넌트 표시/숨김 (계획) + +- [ ] visibility 속성 추가 (모드별 true/false) +- [ ] 속성 패널 체크박스 UI +- [ ] 렌더러에서 visibility 처리 + +## Phase 4: 순서 오버라이드 (계획) + +- [ ] 모드별 children 순서 오버라이드 +- [ ] 드래그로 순서 변경 UI + +--- + +## 완료된 기능 목록 + +### v4 타입 정의 - [x] `PopLayoutDataV4` - 단일 소스 레이아웃 - [x] `PopContainerV4` - 스택 컨테이너 @@ -32,59 +105,70 @@ - [x] `PopResponsiveRuleV4` - 반응형 규칙 - [x] `createEmptyPopLayoutV4()` - 생성 함수 - [x] `isV4Layout()` - 타입 가드 +- [x] `addComponentToV4Layout()` - 컴포넌트 추가 +- [x] `removeComponentFromV4Layout()` - 컴포넌트 삭제 +- [x] `updateComponentInV4Layout()` - 컴포넌트 수정 +- [x] `updateContainerV4()` - 컨테이너 수정 +- [x] `findContainerV4()` - 컨테이너 찾기 -### v4 렌더러 (진행) +### v4 렌더러 -- [ ] `PopFlexRenderer` - Flexbox 기반 렌더링 -- [ ] 컨테이너 재귀 렌더링 -- [ ] 반응형 규칙 적용 (breakpoint) -- [ ] 컴포넌트 숨김 처리 (hideBelow) +- [x] `PopFlexRenderer` - Flexbox 기반 렌더링 +- [x] 컨테이너 재귀 렌더링 (`ContainerRenderer`) +- [x] 반응형 규칙 적용 (`applyResponsiveRules`) +- [x] 컴포넌트 숨김 처리 (`hideBelow`) +- [x] 크기 제약 → CSS 변환 (`calculateSizeStyle`) +- [x] 드래그 리사이즈 핸들 (`ComponentRendererV4`) +- [x] 드래그 앤 드롭 순서 변경 (`DraggableComponentWrapper`) -### 기본 컴포넌트 (대기) +### v4 캔버스 -- [ ] `PopButton` - 터치 버튼 -- [ ] `PopInput` - 텍스트 입력 -- [ ] `PopLabel` - 텍스트 표시 +- [x] `PopCanvasV4` - v4 전용 캔버스 +- [x] 뷰포트 프리셋 (4개 모드) +- [x] 너비 슬라이더 (320~1200px) +- [x] 줌 컨트롤 (30%~150%) +- [x] 패닝 (Space + 드래그) +- [x] 드래그 앤 드롭 + +### v4 속성 패널 + +- [x] `ComponentEditorPanelV4` - 속성 편집 패널 +- [x] 크기 제약 편집 (fixed/fill/hug) +- [x] 컨테이너 설정 (방향, 정렬, 간격) + +### 디자이너 통합 + +- [x] `PopDesigner` v3/v4 자동 판별 +- [x] 새 화면 v4 기본 적용 +- [x] 기존 v3 화면 하위 호환 +- [x] `ComponentPaletteV4` v4 전용 팔레트 (Spacer 포함) +- [x] Undo/Redo 버튼 및 단축키 +- [x] 컴포넌트 순서 변경 핸들러 (`handleReorderComponentV4`) --- ## v3 vs v4 비교 -| | v3 (기존) | v4 (새로운) | -|---|---|---| +| 항목 | v3 (기존) | v4 (새로운) | +|------|-----------|-------------| | 설계 | 4모드 각각 | 1번만 | | 데이터 | col, row 위치 | 규칙 (fill/fixed/hug) | | 렌더링 | CSS Grid | Flexbox | | 반응형 | 수동 | 자동 + 규칙 | +| 새 화면 | - | 기본 적용 | --- -## 컴포넌트 로드맵 +## 관련 파일 -### Tier 1: Primitive (기본) - -| 컴포넌트 | 용도 | 상태 | -|----------|------|------| -| PopButton | 터치 버튼 | 대기 | -| PopInput | 텍스트 입력 | 대기 | -| PopLabel | 텍스트 표시 | 대기 | -| PopBadge | 상태 배지 | 계획 | - -### Tier 2: Compound (조합) - -| 컴포넌트 | 용도 | 상태 | -|----------|------|------| -| PopFormField | 라벨 + 입력 | 계획 | -| PopCard | 카드 컨테이너 | 계획 | -| PopListItem | 목록 항목 | 계획 | - -### Tier 3: Complex (복합) - -| 컴포넌트 | 용도 | 상태 | -|----------|------|------| -| PopScanner | 바코드/QR 스캔 | 계획 | -| PopNumpad | 숫자 키패드 | 계획 | -| PopDataList | 페이징 목록 | 계획 | +| 파일 | 역할 | +|------|------| +| `PopDesigner.tsx` | v3/v4 통합 디자이너 | +| `PopCanvasV4.tsx` | v4 캔버스 (4개 프리셋 + 슬라이더) | +| `PopFlexRenderer.tsx` | v4 Flexbox 렌더러 | +| `ComponentPaletteV4.tsx` | v4 컴포넌트 팔레트 | +| `ComponentEditorPanelV4.tsx` | v4 속성 편집 패널 | +| `pop-layout.ts` | v3/v4 타입 정의 | --- diff --git a/popdocs/STORAGE_RULES.md b/popdocs/STORAGE_RULES.md new file mode 100644 index 00000000..87db9de4 --- /dev/null +++ b/popdocs/STORAGE_RULES.md @@ -0,0 +1,183 @@ +# POP 저장/조회 규칙 + +**AI가 POP 관련 저장/조회 요청을 처리할 때 참고하는 규칙** + +--- + +## 1. 저장 요청 처리 + +사용자가 저장/기록/정리/업데이트 등을 요청하면: + +### 1.1 파일 관련 + +| 요청 유형 | 처리 방법 | +|----------|----------| +| 새 파일 추가됨 | `FILES.md`에 파일 정보 추가 | +| 구조 변경됨 | `ARCHITECTURE.md` 업데이트 | +| 작업 완료 | `CHANGELOG.md`에 기록 | +| 중요 결정 | `decisions/` 폴더에 ADR 추가 | + +### 1.2 rangraph 동기화 + +| 요청 유형 | rangraph 처리 | +|----------|--------------| +| 중요 결정 | `save_decision` 호출 | +| 교훈/규칙 | `save_lesson` 호출 | +| 새 키워드 | `add_keyword` 호출 | +| 작업 흐름 | `workflow_submit` 호출 | + +### 1.3 예시 + +``` +사용자: "오늘 작업 정리해줘" +AI: +1. CHANGELOG.md에 오늘 날짜로 Added/Changed/Fixed 기록 +2. rangraph save_decision 또는 save_lesson 호출 +3. 필요시 FILES.md, ARCHITECTURE.md 업데이트 +``` + +--- + +## 2. 조회 요청 처리 + +사용자가 조회/검색/찾기 등을 요청하면: + +### 2.1 popdocs 우선순위 + +| 필요한 정보 | 참조 문서 | 토큰 비용 | +|------------|----------|----------| +| 빠른 참조 | `README.md` | 낮음 (151줄) | +| 전체 구조 | `ARCHITECTURE.md` | 중간 (530줄) | +| 파일 위치 | `FILES.md` | 높음 (900줄) | +| 기술 스펙 | `SPEC.md` | 중간 | +| 컴포넌트 | `components-spec.md` | 중간 | +| 진행 상황 | `PLAN.md` | 낮음 | +| 변경 이력 | `CHANGELOG.md` | 낮음 | + +### 2.2 조회 전략 + +``` +1단계: rangraph search_memory로 빠르게 확인 +2단계: 관련 popdocs 문서 참조 +3단계: 필요시 실제 소스 파일 Read +``` + +### 2.3 예시 + +``` +사용자: "v4 렌더러 어디있어?" +AI: +1. rangraph search_memory "v4 렌더러" (캐시된 정보) +2. FILES.md에서 확인: PopFlexRenderer.tsx (498줄) +3. 필요시 실제 파일 Read +``` + +--- + +## 3. 토큰 효율화 전략 + +### 3.1 문서 읽기 우선순위 + +``` +최소 토큰: README.md (빠른 참조) + ↓ +중간 토큰: ARCHITECTURE.md (구조 이해) + ↓ +필요시: FILES.md (파일 상세) + ↓ +마지막: 실제 소스 파일 +``` + +### 3.2 효율적 패턴 + +| 상황 | 효율적 방법 | 비효율적 방법 | +|------|------------|--------------| +| 파일 위치 찾기 | FILES.md 검색 | 전체 폴더 탐색 | +| 구조 이해 | ARCHITECTURE.md | 모든 파일 읽기 | +| 빠른 확인 | rangraph 검색 | 문서 전체 읽기 | +| 특정 코드 | FILES.md → Read | Glob + 전체 Read | + +### 3.3 캐싱 활용 + +rangraph에 저장된 정보: +- POP 문서 구조 (save_decision) +- 저장/조회 규칙 (save_lesson) +- 핵심 파일 위치 (save_lesson) +- 키워드: popdocs, ARCHITECTURE.md, FILES.md + +--- + +## 4. 문서별 용도 + +| 문서 | 읽을 때 | 수정할 때 | +|------|--------|----------| +| README.md | 빠른 참조 필요 시 | 문서 구조 변경 시 | +| ARCHITECTURE.md | 구조 이해 필요 시 | 폴더/모듈 변경 시 | +| FILES.md | 파일 찾을 때 | 파일 추가/삭제 시 | +| SPEC.md | 기술 스펙 확인 시 | 스펙 변경 시 | +| PLAN.md | 진행 상황 확인 시 | 계획 변경 시 | +| CHANGELOG.md | 이력 확인 시 | 작업 완료 시 | + +--- + +## 5. 실제 처리 예시 + +### 5.1 저장 요청 + +``` +사용자: "PopButton 컴포넌트 만들었어" + +AI 처리: +1. FILES.md에 파일 정보 추가 +2. CHANGELOG.md에 Added 기록 +3. components-spec.md에 스펙 추가 (필요시) +4. rangraph save_decision 호출 +``` + +### 5.2 조회 요청 + +``` +사용자: "v4 캔버스 어떻게 동작해?" + +AI 처리: +1. rangraph search_memory "v4 캔버스" +2. FILES.md에서 PopCanvasV4.tsx 확인 +3. ARCHITECTURE.md에서 캔버스 섹션 확인 +4. 필요시 PopCanvasV4.tsx 직접 Read +``` + +### 5.3 업데이트 요청 + +``` +사용자: "PLAN.md 업데이트해줘" + +AI 처리: +1. 현재 PLAN.md 읽기 +2. 완료된 항목 체크, 새 항목 추가 +3. rangraph에 진행 상황 저장 (필요시) +``` + +--- + +## 6. 키워드 체계 + +rangraph 키워드 카테고리: + +| 카테고리 | 키워드 예시 | +|----------|-----------| +| pop | popdocs, ARCHITECTURE.md, FILES.md | +| v4 | PopFlexRenderer, PopCanvasV4, 제약조건 | +| designer | PopDesigner, PopPanel | + +--- + +## 7. 이 문서의 용도 + +- AI가 POP 관련 요청을 받으면 이 규칙을 참고 +- 저장 시: popdocs 문서 + rangraph 동기화 +- 조회 시: 토큰 효율적인 순서로 확인 +- 사용자가 규칙 변경을 요청하면 이 문서 수정 + +--- + +*최종 업데이트: 2026-02-04* diff --git a/popdocs/V4_UNIFIED_DESIGN_SPEC.md b/popdocs/V4_UNIFIED_DESIGN_SPEC.md new file mode 100644 index 00000000..7b2ee1eb --- /dev/null +++ b/popdocs/V4_UNIFIED_DESIGN_SPEC.md @@ -0,0 +1,252 @@ +# POP v4 통합 설계 모드 스펙 + +**작성일: 2026-02-04** +**상태: Phase 1.5 완료 (Flexbox 가로 배치 + Spacer)** + +--- + +## 개요 + +v3/v4 탭을 제거하고, **v4 자동 모드를 기본**으로 하되 **모드별 오버라이드** 기능을 지원하는 통합 설계 방식. + +--- + +## 핵심 개념 + +### 기존 방식 (v3) +``` +4개 모드 각각 설계 필요 +태블릿 가로: 버튼 → col 1, row 1 +태블릿 세로: 버튼 → col 1, row 5 (따로 설정) +모바일 가로: 버튼 → col 1, row 1 (따로 설정) +모바일 세로: 버튼 → col 1, row 10 (따로 설정) +``` + +### 새로운 방식 (v4 통합) +``` +기본: 태블릿 가로에서 규칙 설정 + 버튼 → width: fill, height: 48px + +결과: 모든 모드에 자동 적용 + 태블릿 가로: 버튼 너비 1024px, 높이 48px + 태블릿 세로: 버튼 너비 768px, 높이 48px + 모바일 가로: 버튼 너비 667px, 높이 48px + 모바일 세로: 버튼 너비 375px, 높이 48px + +예외: 특정 모드에서 편집하면 오버라이드 + 모바일 세로: 버튼 높이 36px (수동 설정) +``` + +--- + +## 현재 UI (Phase 1.5 완료) + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ ← 목록 화면명 *변경됨 [↶][↷] 자동 레이아웃 (v4) [저장] │ +├─────────────────────────────────────────────────────────────────┤ +│ 편집 중: v4 (자동 반응형) │ +│ 규칙 기반 레이아웃 │ +├────────────┬────────────────────────────────────┬───────────────┤ +│ 컴포넌트 │ 미리보기: [모바일↕][모바일↔] │ 속성 │ +│ │ [태블릿↕][태블릿↔(기본)] │ │ +│ 필드 │ 너비: [====●====] 1024 x 768 │ │ +│ 버튼 │ │ │ +│ 리스트 │ ┌──────────────────────────────┐ │ │ +│ 인디케이터 │ │ [필드1] [필드2] [필드3] │ │ │ +│ 스캐너 │ │ [필드4] [Spacer] [버튼] │ │ │ +│ 숫자패드 │ │ │ │ │ +│ 스페이서 │ │ (가로 배치 + 자동 줄바꿈) │ │ │ +│ │ │ (스크롤 가능) │ │ │ +│ │ └──────────────────────────────┘ │ │ +│ │ 태블릿 가로 (1024x768) │ │ +└────────────┴────────────────────────────────────┴───────────────┘ +``` + +### 레이아웃 방식 (업계 표준) + +| 서비스 | 방식 | +|--------|------| +| Figma | Auto Layout (Flexbox) | +| Webflow | Flexbox + CSS Grid | +| FlutterFlow | Row/Column/Stack | +| Adalo 2.0 | Flexbox + Constraints | +| **POP v4** | **Flexbox (horizontal + wrap)** | + +### Spacer 컴포넌트 사용법 + +``` +[버튼A] [Spacer(fill)] [버튼B] → 버튼B가 오른쪽 끝으로 +[Spacer] [컴포넌트] [Spacer] → 컴포넌트가 가운데로 +[Spacer(fill)] [컴포넌트] → 컴포넌트가 오른쪽으로 +``` + +### 프리셋 버튼 (4개 모드) + +| 버튼 | 해상도 | 설명 | +|------|--------|------| +| 모바일↕ | 375 x 667 | 모바일 세로 | +| 모바일↔ | 667 x 375 | 모바일 가로 | +| 태블릿↕ | 768 x 1024 | 태블릿 세로 | +| 태블릿↔* | 1024 x 768 | 태블릿 가로 (기본) | + +### 레이아웃 판별 로직 + +```typescript +// 새 화면 또는 빈 레이아웃 → v4로 시작 +const hasValidLayout = loadedLayout && loadedLayout.version; +const hasComponents = loadedLayout?.components && + Object.keys(loadedLayout.components).length > 0; + +if (hasValidLayout && hasComponents) { + // v4면 v4, 그 외 v3로 변환 +} else { + // v4로 새로 시작 +} +``` + +--- + +## 오버라이드 동작 (Phase 2 예정) + +### 자동 감지 방식 +1. 사용자가 **태블릿 가로(기본)**에서 편집 → 기본 규칙 저장 +2. 사용자가 **다른 모드**에서 편집 → 해당 모드 오버라이드 자동 저장 +3. 편집 안 한 모드 → 기본 규칙에서 자동 계산 + +### 편집 상태 표시 + +| 상태 | 버튼 색상 | 설명 | +|------|----------|------| +| 기본 (태블릿 가로) | 강조 + "(기본)" | 항상 표시 | +| 자동 | 기본 색상 | 편집 안 함 | +| 편집됨 | 강조 색상 | 오버라이드 있음 | + +### 되돌리기 +- 편집된 모드에만 "자동으로 되돌리기" 버튼 활성화 +- 클릭 시 오버라이드 삭제 → 기본 규칙 복원 + +--- + +## 데이터 구조 + +### PopLayoutDataV4 (Phase 2에서 수정 예정) +```typescript +interface PopLayoutDataV4 { + version: "pop-4.0"; + root: PopContainerV4; + components: Record; + dataFlow: PopDataFlow; + settings: PopGlobalSettingsV4; + + // 모드별 오버라이드 (Phase 2에서 추가) + overrides?: { + mobile_portrait?: ModeOverride; + mobile_landscape?: ModeOverride; + tablet_portrait?: ModeOverride; + // tablet_landscape는 기본이므로 오버라이드 없음 + }; +} + +interface ModeOverride { + components?: Record>; + containers?: Record>; +} +``` + +### PopComponentDefinitionV4 (Phase 3에서 수정 예정) +```typescript +interface PopComponentDefinitionV4 { + type: PopComponentType; + label?: string; + size: PopSizeConstraintV4; + alignSelf?: "start" | "center" | "end" | "stretch"; + + // 모드별 표시 설정 (Phase 3에서 추가) + visibility?: { + mobile_portrait?: boolean; // 기본 true + mobile_landscape?: boolean; // 기본 true + tablet_portrait?: boolean; // 기본 true + tablet_landscape?: boolean; // 기본 true + }; +} +``` + +--- + +## 컴포넌트 표시/숨김 (Phase 3 예정) + +### 업계 표준 (Webflow, Figma) +- 삭제가 아닌 **숨김** 처리 +- 특정 모드에서만 `display: none` +- 언제든 다시 표시 가능 + +### UI (속성 패널) +``` +┌─────────────────────────┐ +│ 버튼 │ +├─────────────────────────┤ +│ 표시 설정 │ +│ [x] 모바일 세로 │ +│ [x] 모바일 가로 │ +│ [x] 태블릿 세로 │ +│ [x] 태블릿 가로 │ +│ │ +│ (체크 해제 = 숨김) │ +└─────────────────────────┘ +``` + +--- + +## 구현 상태 + +### Phase 1: 기본 구조 (완료) +- [x] v3/v4 탭 제거 (자동 판별) +- [x] 새 화면 → v4로 시작 +- [x] 기존 v3 화면 → v3로 로드 (하위 호환) +- [x] 4개 프리셋 버튼 (모바일↕, 모바일↔, 태블릿↕, 태블릿↔) +- [x] 기본 프리셋 표시 (태블릿 가로 + "(기본)") +- [x] 슬라이더 유지 (320~1200px, 비율 유지) +- [x] ComponentPaletteV4 생성 + +### Phase 1.5: Flexbox 가로 배치 (완료) +- [x] Undo/Redo (Ctrl+Z / Ctrl+Shift+Z, 데스크탑 모드와 동일 방식) +- [x] 드래그 리사이즈 핸들 +- [x] Flexbox 가로 배치 (`direction: horizontal`, `wrap: true`) +- [x] 컴포넌트 타입별 기본 크기 설정 +- [x] Spacer 컴포넌트 (`pop-spacer`) +- [x] 컴포넌트 순서 변경 (드래그 앤 드롭) +- [x] 디바이스 스크린 무한 스크롤 + +### Phase 2: 오버라이드 기능 (다음) +- [ ] ModeOverride 데이터 구조 추가 +- [ ] 편집 감지 → 자동 오버라이드 저장 +- [ ] 편집 상태 표시 (버튼 색상) +- [ ] "자동으로 되돌리기" 버튼 + +### Phase 3: 컴포넌트 표시/숨김 +- [ ] visibility 속성 추가 +- [ ] 속성 패널 체크박스 UI +- [ ] 렌더러에서 visibility 처리 + +### Phase 4: 순서 오버라이드 +- [ ] 모드별 children 순서 오버라이드 +- [ ] 드래그로 순서 변경 UI + +--- + +## 관련 파일 + +| 파일 | 역할 | 상태 | +|------|------|------| +| `PopDesigner.tsx` | v3/v4 통합 디자이너 | 완료 | +| `PopCanvasV4.tsx` | v4 캔버스 (4개 프리셋 + 슬라이더) | 완료 | +| `PopFlexRenderer.tsx` | v4 Flexbox 렌더러 | 완료 | +| `ComponentPaletteV4.tsx` | v4 컴포넌트 팔레트 | 완료 | +| `ComponentEditorPanelV4.tsx` | v4 속성 편집 패널 | 완료 | +| `pop-layout.ts` | v3/v4 타입 정의 | 완료, Phase 2-3에서 수정 예정 | + +--- + +*이 문서는 v4 통합 설계 모드의 스펙을 정의합니다.* +*최종 업데이트: 2026-02-04* diff --git a/popdocs/components-spec.md b/popdocs/components-spec.md index e12195ce..a3fd66a5 100644 --- a/popdocs/components-spec.md +++ b/popdocs/components-spec.md @@ -6,11 +6,11 @@ ## Quick Reference -### 총 컴포넌트 수: 13개 +### 총 컴포넌트 수: 14개 | 분류 | 개수 | 컴포넌트 | |------|------|----------| -| 레이아웃 | 2 | container, tab-panel | +| 레이아웃 | 3 | container, tab-panel, **spacer** | | 데이터 표시 | 4 | data-table, card-list, kpi-gauge, status-indicator | | 입력 | 4 | number-pad, barcode-scanner, form-field, action-button | | 특화 기능 | 3 | timer, alarm-list, process-flow | @@ -37,17 +37,18 @@ |---|----------|------| | 1 | pop-container | 레이아웃 뼈대 | | 2 | pop-tab-panel | 정보 분류 | -| 3 | pop-data-table | 대량 데이터 | -| 4 | pop-card-list | 시각적 목록 | -| 5 | pop-kpi-gauge | 목표 달성률 | -| 6 | pop-status-indicator | 상태 표시 | -| 7 | pop-number-pad | 수량 입력 | -| 8 | pop-barcode-scanner | 스캔 입력 | -| 9 | pop-form-field | 범용 입력 | -| 10 | pop-action-button | 작업 실행 | -| 11 | pop-timer | 시간 측정 | -| 12 | pop-alarm-list | 알람 관리 | -| 13 | pop-process-flow | 공정 현황 | +| 3 | **pop-spacer** | **빈 공간 (정렬용)** | +| 4 | pop-data-table | 대량 데이터 | +| 5 | pop-card-list | 시각적 목록 | +| 6 | pop-kpi-gauge | 목표 달성률 | +| 7 | pop-status-indicator | 상태 표시 | +| 8 | pop-number-pad | 수량 입력 | +| 9 | pop-barcode-scanner | 스캔 입력 | +| 10 | pop-form-field | 범용 입력 | +| 11 | pop-action-button | 작업 실행 | +| 12 | pop-timer | 시간 측정 | +| 13 | pop-alarm-list | 알람 관리 | +| 14 | pop-process-flow | 공정 현황 | --- @@ -79,6 +80,35 @@ --- +## 3. pop-spacer (v4 전용) + +역할: 빈 공간을 차지하여 레이아웃 정렬에 사용 (Figma, Webflow 등 업계 표준) + +| 기능 | 설명 | +|------|------| +| 공간 채우기 | 남은 공간을 자동으로 채움 (`width: fill`) | +| 고정 크기 | 특정 크기의 빈 공간 (`width: fixed`) | +| 정렬 용도 | 컴포넌트를 오른쪽/가운데 정렬 | + +### 사용 예시 + +``` +[버튼A] [Spacer(fill)] [버튼B] → 버튼B가 오른쪽 끝으로 +[Spacer] [컴포넌트] [Spacer] → 컴포넌트가 가운데로 +[Spacer(fill)] [컴포넌트] → 컴포넌트가 오른쪽으로 +``` + +### 기본 설정 + +| 속성 | 기본값 | +|------|--------| +| width | fill (남은 공간 채움) | +| height | 48px (고정) | +| 디자인 모드 표시 | 점선 배경 + "빈 공간" 텍스트 | +| 실제 모드 | 완전히 투명 (공간만 차지) | + +--- + ## 3. pop-data-table 역할: 대량 데이터 표시, 선택, 편집