feat(pop): v4 레이아웃 비율 스케일링 시스템 구현

- PopFlexRenderer에 BASE_VIEWPORT_WIDTH(1024px) 기준 스케일 계산 추가
- 컴포넌트 크기(fixedWidth/Height), gap, padding에 scale 적용
- 뷰어에서 viewportWidth 동적 감지 및 최대 1366px 제한
- 디자인 모드에서는 scale=1 유지, 뷰어에서만 비율 적용
- DndProvider 없는 환경에서 useDrag/useDrop 에러 방지
- v4 레이아웃 뷰어 렌더링 지원 (isPopLayoutV4 체크)
This commit is contained in:
SeongHyun Kim 2026-02-04 14:14:48 +09:00
parent 223f5c0251
commit 6572519092
17 changed files with 5239 additions and 205 deletions

View File

@ -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<ScreenDefinition | null>(null);
const [layout, setLayout] = useState<LayoutData | null>(null);
const [popLayoutV3, setPopLayoutV3] = useState<PopLayoutDataV3 | null>(null);
const [popLayoutV4, setPopLayoutV4] = useState<PopLayoutDataV4 | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -108,6 +118,19 @@ function PopScreenViewPage() {
const [selectedRowsData, setSelectedRowsData] = useState<any[]>([]);
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 ? (
<div
className="mx-auto h-full"
style={{ maxWidth: 1366 }}
>
<PopFlexRenderer
layout={popLayoutV4}
viewportWidth={isPreviewMode ? currentDevice.width : viewportWidth}
isDesignMode={false}
/>
</div>
) : popLayoutV3 ? (
/* POP 레이아웃 v3.0 렌더링 */
<PopLayoutV3Renderer
layout={popLayoutV3}
modeKey={currentModeKey}

View File

@ -0,0 +1,150 @@
"use client";
import { useState, useCallback } from "react";
import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
import {
PopLayoutDataV4,
createEmptyPopLayoutV4,
addComponentToV4Layout,
removeComponentFromV4Layout,
updateComponentInV4Layout,
updateContainerV4,
findContainerV4,
PopComponentType,
PopComponentDefinitionV4,
PopContainerV4,
} from "@/components/pop/designer/types/pop-layout";
import { PopCanvasV4 } from "@/components/pop/designer/PopCanvasV4";
import { PopPanel } from "@/components/pop/designer/panels/PopPanel";
import { ComponentEditorPanelV4 } from "@/components/pop/designer/panels/ComponentEditorPanelV4";
// ========================================
// v4 테스트 페이지
//
// 목적: v4 렌더러, 캔버스, 속성 패널 테스트
// 경로: /pop/test-v4
// ========================================
export default function TestV4Page() {
// 레이아웃 상태
const [layout, setLayout] = useState<PopLayoutDataV4>(() => {
// 초기 테스트 데이터
const initial = createEmptyPopLayoutV4();
return initial;
});
// 선택 상태
const [selectedComponentId, setSelectedComponentId] = useState<string | null>(null);
const [selectedContainerId, setSelectedContainerId] = useState<string | null>(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<PopComponentDefinitionV4>) => {
setLayout((prev) => updateComponentInV4Layout(prev, componentId, updates));
},
[]
);
// 컨테이너 업데이트
const handleUpdateContainer = useCallback(
(containerId: string, updates: Partial<PopContainerV4>) => {
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 (
<DndProvider backend={HTML5Backend}>
<div className="flex h-screen w-screen overflow-hidden bg-gray-100">
{/* 왼쪽: 컴포넌트 팔레트 */}
<div className="w-64 shrink-0 border-r bg-white">
<div className="border-b px-4 py-3">
<h2 className="text-sm font-semibold">v4 </h2>
<p className="text-xs text-muted-foreground"> </p>
</div>
<PopPanel />
</div>
{/* 중앙: 캔버스 */}
<div className="flex-1 overflow-hidden">
<PopCanvasV4
layout={layout}
selectedComponentId={selectedComponentId}
selectedContainerId={selectedContainerId}
onSelectComponent={handleSelectComponent}
onSelectContainer={handleSelectContainer}
onDropComponent={handleDropComponent}
onUpdateComponent={handleUpdateComponent}
onUpdateContainer={handleUpdateContainer}
onDeleteComponent={handleDeleteComponent}
/>
</div>
{/* 오른쪽: 속성 패널 */}
<div className="w-72 shrink-0 border-l bg-white">
<ComponentEditorPanelV4
component={selectedComponent}
container={selectedContainer}
onUpdateComponent={
selectedComponentId
? (updates) => handleUpdateComponent(selectedComponentId, updates)
: undefined
}
onUpdateContainer={
selectedContainerId
? (updates) => handleUpdateContainer(selectedContainerId, updates)
: undefined
}
/>
</div>
</div>
</DndProvider>
);
}

View File

@ -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<PopComponentDefinitionV4>) => void;
onUpdateContainer: (containerId: string, updates: Partial<PopContainerV4>) => void;
onDeleteComponent: (componentId: string) => void;
onResizeComponent?: (componentId: string, size: Partial<PopSizeConstraintV4>) => 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<ViewportPreset>(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<HTMLDivElement>(null);
const dropRef = useRef<HTMLDivElement>(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 (
<div className="relative flex h-full flex-col bg-gray-100">
{/* 툴바 */}
<div className="flex shrink-0 items-center justify-between border-b bg-white px-4 py-2">
{/* 뷰포트 프리셋 (4개 모드) */}
<div className="flex items-center gap-1">
<span className="text-xs text-muted-foreground mr-2">:</span>
{VIEWPORT_PRESETS.map((preset) => {
const Icon = preset.icon;
const isActive = activeViewport === preset.id;
const isDefault = preset.id === DEFAULT_PRESET;
return (
<Button
key={preset.id}
variant={isActive ? "default" : "outline"}
size="sm"
className={cn(
"h-8 gap-1 text-xs",
isDefault && !isActive && "border-primary/50"
)}
onClick={() => handleViewportChange(preset.id)}
title={preset.label}
>
<Icon className={cn("h-4 w-4", preset.isLandscape && "rotate-90")} />
<span className="hidden lg:inline">{preset.shortLabel}</span>
{isDefault && (
<span className="text-[10px] text-muted-foreground ml-1">()</span>
)}
</Button>
);
})}
</div>
{/* 줌 컨트롤 */}
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
{Math.round(canvasScale * 100)}%
</span>
<Button variant="outline" size="icon" className="h-7 w-7" onClick={handleZoomOut}>
<ZoomOut className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" className="h-7 w-7" onClick={handleZoomIn}>
<ZoomIn className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" className="h-7 w-7" onClick={handleZoomFit}>
<Maximize2 className="h-4 w-4" />
</Button>
</div>
</div>
{/* 뷰포트 크기 슬라이더 */}
<div className="flex shrink-0 items-center gap-4 border-b bg-gray-50 px-4 py-2">
<span className="text-xs text-muted-foreground">:</span>
<input
type="range"
min={320}
max={1200}
value={customWidth}
onChange={(e) => handleWidthChange(Number(e.target.value))}
className="flex-1 h-1 bg-gray-300 rounded-lg appearance-none cursor-pointer"
/>
<span className="text-xs font-mono w-24 text-right">
{customWidth} x {viewportHeight}
</span>
</div>
{/* 캔버스 영역 */}
<div
ref={containerRef}
className={cn(
"relative flex-1 overflow-auto",
isPanning && "cursor-grabbing",
isSpacePressed && "cursor-grab"
)}
onMouseDown={handlePanStart}
onMouseMove={handlePanMove}
onMouseUp={handlePanEnd}
onMouseLeave={handlePanEnd}
onWheel={handleWheel}
>
<div
className="canvas-scroll-area flex items-center justify-center"
style={{ padding: "100px", minWidth: "fit-content", minHeight: "fit-content" }}
>
{/* 디바이스 프레임 */}
<div
ref={dropRef}
className={cn(
"relative rounded-xl bg-white shadow-lg transition-all",
"ring-2 ring-primary ring-offset-2",
isOver && canDrop && "ring-4 ring-green-500 bg-green-50"
)}
style={{
width: viewportWidth * canvasScale,
height: viewportHeight * canvasScale,
overflow: "auto", // 컴포넌트가 넘치면 스크롤 가능
}}
>
{/* 뷰포트 라벨 */}
<div className="absolute -top-6 left-0 text-xs font-medium text-muted-foreground">
{currentPreset.label} ({viewportWidth}x{viewportHeight})
</div>
{/* Flexbox 렌더러 - 최소 높이는 뷰포트 높이, 컨텐츠에 따라 늘어남 */}
<div
className="origin-top-left"
style={{
transform: `scale(${canvasScale})`,
width: viewportWidth,
minHeight: viewportHeight, // height → minHeight로 변경
}}
>
<PopFlexRenderer
layout={layout}
viewportWidth={viewportWidth}
isDesignMode={true}
selectedComponentId={selectedComponentId}
onComponentClick={onSelectComponent}
onContainerClick={onSelectContainer}
onBackgroundClick={() => {
onSelectComponent(null);
onSelectContainer(null);
}}
onComponentResize={onResizeComponent}
onReorderComponent={onReorderComponent}
/>
</div>
{/* 드롭 안내 (빈 상태) */}
{layout.root.children.length === 0 && (
<div
className={cn(
"absolute inset-0 flex items-center justify-center",
isOver && canDrop ? "text-green-600" : "text-gray-400"
)}
>
<div className="text-center">
<p className="text-sm font-medium">
{isOver && canDrop
? "여기에 놓으세요"
: "컴포넌트를 드래그하세요"}
</p>
<p className="text-xs mt-1">
</p>
</div>
</div>
)}
</div>
</div>
</div>
</div>
);
}
export default PopCanvasV4;

View File

@ -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<PopLayoutDataV3>(createEmptyPopLayoutV3());
const [layoutMode, setLayoutMode] = useState<LayoutMode>("v4");
// ========================================
// 레이아웃 상태 (데스크탑 모드와 동일한 방식)
// ========================================
const [layoutV4, setLayoutV4] = useState<PopLayoutDataV4>(createEmptyPopLayoutV4());
const [layoutV3, setLayoutV3] = useState<PopLayoutDataV3>(createEmptyPopLayoutV3());
// 히스토리 (v4용)
const [historyV4, setHistoryV4] = useState<PopLayoutDataV4[]>([]);
const [historyIndexV4, setHistoryIndexV4] = useState(-1);
// 히스토리 (v3용)
const [historyV3, setHistoryV3] = useState<PopLayoutDataV3[]>([]);
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<DeviceType>("tablet");
const [activeModeKey, setActiveModeKey] = useState<PopLayoutModeKey>("tablet_landscape");
// ========================================
// 선택 상태 (v3: 섹션 없음, 컴포넌트만)
// 선택 상태
// ========================================
const [selectedComponentId, setSelectedComponentId] = useState<string | null>(null);
const [selectedContainerId, setSelectedContainerId] = useState<string | null>(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<PopComponentDefinition>) => {
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<PopComponentDefinitionV4>) => {
const newLayout = updateComponentInV4Layout(layoutV4, componentId, updates);
setLayoutV4(newLayout);
saveToHistoryV4(newLayout);
setHasChanges(true);
},
[layoutV4, saveToHistoryV4]
);
const handleUpdateContainerV4 = useCallback(
(containerId: string, updates: Partial<PopContainerV4>) => {
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<PopSizeConstraintV4>) => {
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({
<span className="text-sm font-medium">
{selectedScreen?.screenName || "POP 화면"}
</span>
{hasChanges && (
<span className="text-xs text-orange-500">*</span>
{hasChanges && <span className="text-xs text-orange-500">*</span>}
</div>
{/* 중앙: 레이아웃 버전 + v3 디바이스 전환 */}
<div className="flex items-center gap-4">
<span className="text-muted-foreground text-xs">
{layoutMode === "v4" ? "자동 레이아웃 (v4)" : "4모드 레이아웃 (v3)"}
</span>
{layoutMode === "v3" && (
<Tabs value={activeDevice} onValueChange={(v) => handleDeviceChange(v as DeviceType)}>
<TabsList className="h-8">
<TabsTrigger value="tablet" className="h-7 px-3 text-xs">
<Tablet className="mr-1 h-3.5 w-3.5" />
릿
</TabsTrigger>
<TabsTrigger value="mobile" className="h-7 px-3 text-xs">
<Smartphone className="mr-1 h-3.5 w-3.5" />
</TabsTrigger>
</TabsList>
</Tabs>
)}
</div>
{/* 중앙: 디바이스 전환 */}
{/* 오른쪽: Undo/Redo + 저장 */}
<div className="flex items-center gap-2">
<Tabs
value={activeDevice}
onValueChange={(v) => handleDeviceChange(v as DeviceType)}
>
<TabsList className="h-8">
<TabsTrigger value="tablet" className="h-7 px-3 text-xs">
<Tablet className="mr-1 h-3.5 w-3.5" />
릿
</TabsTrigger>
<TabsTrigger value="mobile" className="h-7 px-3 text-xs">
<Smartphone className="mr-1 h-3.5 w-3.5" />
</TabsTrigger>
</TabsList>
</Tabs>
</div>
{/* 오른쪽: 저장 */}
<div>
<Button
size="sm"
onClick={handleSave}
disabled={isSaving || !hasChanges}
>
{/* Undo/Redo 버튼 */}
<div className="flex items-center gap-1 border-r pr-2 mr-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => {
handleUndo();
setHasChanges(true);
toast.success("실행 취소됨");
}}
disabled={!canUndo}
title="실행 취소 (Ctrl+Z)"
>
<Undo2 className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => {
handleRedo();
setHasChanges(true);
toast.success("다시 실행됨");
}}
disabled={!canRedo}
title="다시 실행 (Ctrl+Shift+Z)"
>
<Redo2 className="h-4 w-4" />
</Button>
</div>
{/* 저장 버튼 */}
<Button size="sm" onClick={handleSave} disabled={isSaving || !hasChanges}>
<Save className="mr-1 h-4 w-4" />
{isSaving ? "저장 중..." : "저장"}
</Button>
@ -309,40 +625,78 @@ export default function PopDesigner({
{/* 메인 영역 */}
<ResizablePanelGroup direction="horizontal" className="flex-1">
{/* 왼쪽: 패널 */}
<ResizablePanel
defaultSize={20}
minSize={15}
maxSize={30}
className="border-r"
>
<PopPanel
layout={layout}
activeModeKey={activeModeKey}
selectedComponentId={selectedComponentId}
selectedComponent={selectedComponent}
onUpdateComponentDefinition={handleUpdateComponentDefinition}
onDeleteComponent={handleDeleteComponent}
activeDevice={activeDevice}
/>
{/* 왼쪽: 컴포넌트 패널 */}
<ResizablePanel defaultSize={20} minSize={15} maxSize={30} className="border-r">
{layoutMode === "v3" ? (
<PopPanel
layout={layoutV3}
activeModeKey={activeModeKey}
selectedComponentId={selectedComponentId}
selectedComponent={selectedComponentV3}
onUpdateComponentDefinition={handleUpdateComponentDefinitionV3}
onDeleteComponent={handleDeleteComponentV3}
activeDevice={activeDevice}
/>
) : (
<ComponentPaletteV4 />
)}
</ResizablePanel>
<ResizableHandle withHandle />
{/* 오른쪽: 캔버스 */}
<ResizablePanel defaultSize={80}>
<PopCanvas
layout={layout}
activeDevice={activeDevice}
activeModeKey={activeModeKey}
onModeKeyChange={handleModeKeyChange}
selectedComponentId={selectedComponentId}
onSelectComponent={setSelectedComponentId}
onUpdateComponentPosition={handleUpdateComponentPosition}
onDropComponent={handleDropComponent}
onDeleteComponent={handleDeleteComponent}
/>
{/* 중앙: 캔버스 */}
<ResizablePanel defaultSize={layoutMode === "v3" ? 80 : 60}>
{layoutMode === "v3" ? (
<PopCanvas
layout={layoutV3}
activeDevice={activeDevice}
activeModeKey={activeModeKey}
onModeKeyChange={handleModeKeyChange}
selectedComponentId={selectedComponentId}
onSelectComponent={setSelectedComponentId}
onUpdateComponentPosition={handleUpdateComponentPositionV3}
onDropComponent={handleDropComponentV3}
onDeleteComponent={handleDeleteComponentV3}
/>
) : (
<PopCanvasV4
layout={layoutV4}
selectedComponentId={selectedComponentId}
selectedContainerId={selectedContainerId}
onSelectComponent={setSelectedComponentId}
onSelectContainer={setSelectedContainerId}
onDropComponent={handleDropComponentV4}
onUpdateComponent={handleUpdateComponentV4}
onUpdateContainer={handleUpdateContainerV4}
onDeleteComponent={handleDeleteComponentV4}
onResizeComponent={handleResizeComponentV4}
onReorderComponent={handleReorderComponentV4}
/>
)}
</ResizablePanel>
{/* 오른쪽: 속성 패널 (v4만) */}
{layoutMode === "v4" && (
<>
<ResizableHandle withHandle />
<ResizablePanel defaultSize={20} minSize={15} maxSize={30}>
<ComponentEditorPanelV4
component={selectedComponentV4}
container={selectedContainer}
onUpdateComponent={
selectedComponentId
? (updates) => handleUpdateComponentV4(selectedComponentId, updates)
: undefined
}
onUpdateContainer={
selectedContainerId
? (updates) => handleUpdateContainerV4(selectedContainerId, updates)
: undefined
}
/>
</ResizablePanel>
</>
)}
</ResizablePanelGroup>
</div>
</DndProvider>

View File

@ -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<PopComponentDefinitionV4>) => void;
/** 컨테이너 업데이트 */
onUpdateContainer?: (updates: Partial<PopContainerV4>) => void;
/** 추가 className */
className?: string;
}
// ========================================
// 컴포넌트 타입별 라벨
// ========================================
const COMPONENT_TYPE_LABELS: Record<PopComponentType, string> = {
"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 (
<div className={cn("flex h-full flex-col", className)}>
<div className="border-b px-4 py-3">
<h3 className="text-sm font-medium"></h3>
</div>
<div className="flex flex-1 items-center justify-center p-4 text-sm text-muted-foreground">
</div>
</div>
);
}
// 컨테이너가 선택된 경우
if (container) {
return (
<div className={cn("flex h-full flex-col", className)}>
<div className="border-b px-4 py-3">
<h3 className="text-sm font-medium"> </h3>
<p className="text-xs text-muted-foreground">{container.id}</p>
</div>
<div className="flex-1 overflow-auto p-4">
<ContainerSettingsForm
container={container}
onUpdate={onUpdateContainer}
/>
</div>
</div>
);
}
// 컴포넌트가 선택된 경우
return (
<div className={cn("flex h-full flex-col", className)}>
{/* 헤더 */}
<div className="border-b px-4 py-3">
<h3 className="text-sm font-medium">
{component!.label || COMPONENT_TYPE_LABELS[component!.type]}
</h3>
<p className="text-xs text-muted-foreground">{component!.type}</p>
</div>
{/* 탭 컨텐츠 */}
<Tabs defaultValue="size" className="flex-1">
<TabsList className="w-full justify-start rounded-none border-b bg-transparent px-2">
<TabsTrigger value="size" className="gap-1 text-xs">
<Maximize2 className="h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="settings" className="gap-1 text-xs">
<Settings className="h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="data" className="gap-1 text-xs">
<Database className="h-3 w-3" />
</TabsTrigger>
</TabsList>
{/* 크기 제약 탭 */}
<TabsContent value="size" className="flex-1 overflow-auto p-4">
<SizeConstraintForm
component={component!}
onUpdate={onUpdateComponent}
/>
</TabsContent>
{/* 기본 설정 탭 */}
<TabsContent value="settings" className="flex-1 overflow-auto p-4">
<ComponentSettingsForm
component={component!}
onUpdate={onUpdateComponent}
/>
</TabsContent>
{/* 데이터 바인딩 탭 */}
<TabsContent value="data" className="flex-1 overflow-auto p-4">
<DataBindingPlaceholder />
</TabsContent>
</Tabs>
</div>
);
}
// ========================================
// 크기 제약 폼
// ========================================
interface SizeConstraintFormProps {
component: PopComponentDefinitionV4;
onUpdate?: (updates: Partial<PopComponentDefinitionV4>) => void;
}
function SizeConstraintForm({ component, onUpdate }: SizeConstraintFormProps) {
const { size } = component;
const handleSizeChange = (
field: keyof PopSizeConstraintV4,
value: string | number | undefined
) => {
onUpdate?.({
size: {
...size,
[field]: value,
},
});
};
return (
<div className="space-y-6">
{/* 너비 설정 */}
<div className="space-y-3">
<Label className="text-xs font-medium flex items-center gap-1">
<MoveHorizontal className="h-3 w-3" />
</Label>
<div className="flex gap-1">
<SizeButton
active={size.width === "fixed"}
onClick={() => handleSizeChange("width", "fixed")}
label="고정"
description="px"
/>
<SizeButton
active={size.width === "fill"}
onClick={() => handleSizeChange("width", "fill")}
label="채움"
description="flex"
/>
<SizeButton
active={size.width === "hug"}
onClick={() => handleSizeChange("width", "hug")}
label="맞춤"
description="auto"
/>
</div>
{/* 고정 너비 입력 */}
{size.width === "fixed" && (
<div className="flex items-center gap-2">
<Input
type="number"
className="h-8 w-24 text-xs"
value={size.fixedWidth || ""}
onChange={(e) =>
handleSizeChange(
"fixedWidth",
e.target.value ? Number(e.target.value) : undefined
)
}
placeholder="너비"
/>
<span className="text-xs text-muted-foreground">px</span>
</div>
)}
{/* 채움일 때 최소/최대 */}
{size.width === "fill" && (
<div className="flex items-center gap-2">
<Input
type="number"
className="h-8 w-20 text-xs"
value={size.minWidth || ""}
onChange={(e) =>
handleSizeChange(
"minWidth",
e.target.value ? Number(e.target.value) : undefined
)
}
placeholder="최소"
/>
<span className="text-xs text-muted-foreground">~</span>
<Input
type="number"
className="h-8 w-20 text-xs"
value={size.maxWidth || ""}
onChange={(e) =>
handleSizeChange(
"maxWidth",
e.target.value ? Number(e.target.value) : undefined
)
}
placeholder="최대"
/>
<span className="text-xs text-muted-foreground">px</span>
</div>
)}
</div>
{/* 높이 설정 */}
<div className="space-y-3">
<Label className="text-xs font-medium flex items-center gap-1">
<MoveVertical className="h-3 w-3" />
</Label>
<div className="flex gap-1">
<SizeButton
active={size.height === "fixed"}
onClick={() => handleSizeChange("height", "fixed")}
label="고정"
description="px"
/>
<SizeButton
active={size.height === "fill"}
onClick={() => handleSizeChange("height", "fill")}
label="채움"
description="flex"
/>
<SizeButton
active={size.height === "hug"}
onClick={() => handleSizeChange("height", "hug")}
label="맞춤"
description="auto"
/>
</div>
{/* 고정 높이 입력 */}
{size.height === "fixed" && (
<div className="flex items-center gap-2">
<Input
type="number"
className="h-8 w-24 text-xs"
value={size.fixedHeight || ""}
onChange={(e) =>
handleSizeChange(
"fixedHeight",
e.target.value ? Number(e.target.value) : undefined
)
}
placeholder="높이"
/>
<span className="text-xs text-muted-foreground">px</span>
</div>
)}
{/* 채움일 때 최소 */}
{size.height === "fill" && (
<div className="flex items-center gap-2">
<Input
type="number"
className="h-8 w-24 text-xs"
value={size.minHeight || ""}
onChange={(e) =>
handleSizeChange(
"minHeight",
e.target.value ? Number(e.target.value) : undefined
)
}
placeholder="최소 높이"
/>
<span className="text-xs text-muted-foreground">px</span>
</div>
)}
</div>
{/* 개별 정렬 */}
<div className="space-y-3">
<Label className="text-xs font-medium flex items-center gap-1">
<AlignCenter className="h-3 w-3" />
</Label>
<Select
value={component.alignSelf || "none"}
onValueChange={(value) =>
onUpdate?.({
alignSelf: value === "none" ? undefined : (value as any),
})
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="컨테이너 설정 따름" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"> </SelectItem>
<SelectItem value="start"></SelectItem>
<SelectItem value="center"></SelectItem>
<SelectItem value="end"></SelectItem>
<SelectItem value="stretch"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 반응형 숨김 */}
<div className="space-y-3">
<Label className="text-xs font-medium"> </Label>
<div className="flex items-center gap-2">
<Input
type="number"
className="h-8 w-24 text-xs"
value={component.hideBelow || ""}
onChange={(e) =>
onUpdate?.({
hideBelow: e.target.value ? Number(e.target.value) : undefined,
})
}
placeholder="없음"
/>
<span className="text-xs text-muted-foreground">px </span>
</div>
</div>
</div>
);
}
// ========================================
// 크기 버튼 컴포넌트
// ========================================
interface SizeButtonProps {
active: boolean;
onClick: () => void;
label: string;
description: string;
}
function SizeButton({ active, onClick, label, description }: SizeButtonProps) {
return (
<button
className={cn(
"flex-1 flex flex-col items-center gap-0.5 rounded-md border p-2 text-xs transition-colors",
active
? "border-primary bg-primary/10 text-primary"
: "border-gray-200 bg-white text-gray-600 hover:bg-gray-50"
)}
onClick={onClick}
>
<span className="font-medium">{label}</span>
<span className="text-[10px] text-muted-foreground">{description}</span>
</button>
);
}
// ========================================
// 컨테이너 설정 폼
// ========================================
interface ContainerSettingsFormProps {
container: PopContainerV4;
onUpdate?: (updates: Partial<PopContainerV4>) => void;
}
function ContainerSettingsForm({
container,
onUpdate,
}: ContainerSettingsFormProps) {
return (
<div className="space-y-6">
{/* 방향 */}
<div className="space-y-3">
<Label className="text-xs font-medium"></Label>
<div className="flex gap-1">
<Button
variant={container.direction === "horizontal" ? "default" : "outline"}
size="sm"
className="flex-1 h-8 text-xs"
onClick={() => onUpdate?.({ direction: "horizontal" })}
>
</Button>
<Button
variant={container.direction === "vertical" ? "default" : "outline"}
size="sm"
className="flex-1 h-8 text-xs"
onClick={() => onUpdate?.({ direction: "vertical" })}
>
</Button>
</div>
</div>
{/* 줄바꿈 */}
<div className="space-y-3">
<Label className="text-xs font-medium"></Label>
<div className="flex gap-1">
<Button
variant={container.wrap ? "default" : "outline"}
size="sm"
className="flex-1 h-8 text-xs"
onClick={() => onUpdate?.({ wrap: true })}
>
</Button>
<Button
variant={!container.wrap ? "default" : "outline"}
size="sm"
className="flex-1 h-8 text-xs"
onClick={() => onUpdate?.({ wrap: false })}
>
</Button>
</div>
</div>
{/* 간격 */}
<div className="space-y-3">
<Label className="text-xs font-medium"> (gap)</Label>
<div className="flex items-center gap-2">
<Input
type="number"
className="h-8 w-24 text-xs"
value={container.gap}
onChange={(e) => onUpdate?.({ gap: Number(e.target.value) || 0 })}
/>
<span className="text-xs text-muted-foreground">px</span>
</div>
</div>
{/* 패딩 */}
<div className="space-y-3">
<Label className="text-xs font-medium"></Label>
<div className="flex items-center gap-2">
<Input
type="number"
className="h-8 w-24 text-xs"
value={container.padding || 0}
onChange={(e) =>
onUpdate?.({ padding: Number(e.target.value) || undefined })
}
/>
<span className="text-xs text-muted-foreground">px</span>
</div>
</div>
{/* 정렬 */}
<div className="space-y-3">
<Label className="text-xs font-medium"> </Label>
<Select
value={container.alignItems}
onValueChange={(value) => onUpdate?.({ alignItems: value as any })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="start"></SelectItem>
<SelectItem value="center"></SelectItem>
<SelectItem value="end"></SelectItem>
<SelectItem value="stretch"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-3">
<Label className="text-xs font-medium"> </Label>
<Select
value={container.justifyContent}
onValueChange={(value) => onUpdate?.({ justifyContent: value as any })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="start"></SelectItem>
<SelectItem value="center"></SelectItem>
<SelectItem value="end"></SelectItem>
<SelectItem value="space-between"> </SelectItem>
</SelectContent>
</Select>
</div>
</div>
);
}
// ========================================
// 컴포넌트 설정 폼
// ========================================
interface ComponentSettingsFormProps {
component: PopComponentDefinitionV4;
onUpdate?: (updates: Partial<PopComponentDefinitionV4>) => void;
}
function ComponentSettingsForm({
component,
onUpdate,
}: ComponentSettingsFormProps) {
return (
<div className="space-y-4">
{/* 라벨 입력 */}
<div className="space-y-1.5">
<Label className="text-xs font-medium"></Label>
<Input
type="text"
className="h-8 text-xs"
value={component.label || ""}
onChange={(e) => onUpdate?.({ label: e.target.value })}
placeholder="컴포넌트 라벨"
/>
</div>
{/* 타입별 설정 (TODO: 상세 구현) */}
<div className="rounded-lg border border-dashed border-gray-300 bg-gray-50 p-4">
<p className="text-center text-xs text-muted-foreground">
{COMPONENT_TYPE_LABELS[component.type]}
</p>
<p className="mt-1 text-center text-xs text-muted-foreground">
( )
</p>
</div>
</div>
);
}
// ========================================
// 데이터 바인딩 플레이스홀더
// ========================================
function DataBindingPlaceholder() {
return (
<div className="space-y-4">
<div className="rounded-lg border border-dashed border-gray-300 bg-gray-50 p-4">
<div className="flex flex-col items-center gap-2">
<Database className="h-8 w-8 text-gray-400" />
<p className="text-center text-xs text-muted-foreground">
</p>
<p className="text-center text-xs text-muted-foreground">
- -
</p>
<p className="mt-2 text-center text-xs text-gray-400">
( )
</p>
</div>
</div>
</div>
);
}
export default ComponentEditorPanelV4;

View File

@ -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 (
<div className="flex h-full flex-col">
{/* 헤더 */}
<div className="border-b p-3">
<div className="text-sm font-medium text-muted-foreground">
: v4 ( )
</div>
<div className="text-xs text-muted-foreground mt-1">
</div>
</div>
{/* 컴포넌트 목록 */}
<div className="flex-1 overflow-auto p-3">
<div className="text-xs font-medium text-muted-foreground mb-2">
</div>
<div className="space-y-1">
{COMPONENT_PALETTE.map((item) => (
<DraggableComponentV4
key={item.type}
type={item.type}
label={item.label}
icon={item.icon}
description={item.description}
/>
))}
</div>
<div className="mt-4 text-xs text-muted-foreground">
</div>
</div>
</div>
);
}
// ========================================
// 드래그 가능한 컴포넌트 아이템
// ========================================
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 (
<div
ref={drag}
className={cn(
"flex items-center gap-2 rounded-lg border p-2 cursor-grab transition-all",
"hover:bg-accent hover:border-primary/30",
isDragging && "opacity-50 cursor-grabbing"
)}
>
<GripVertical className="h-4 w-4 text-muted-foreground" />
<Icon className="h-4 w-4 shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{label}</div>
<div className="text-xs text-muted-foreground truncate">{description}</div>
</div>
</div>
);
}
export default ComponentPaletteV4;

View File

@ -1,3 +1,4 @@
// POP 디자이너 패널 export
export { PopPanel } from "./PopPanel";
export { ComponentEditorPanel } from "./ComponentEditorPanel";
export { ComponentEditorPanelV4 } from "./ComponentEditorPanelV4";

View File

@ -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<PopSizeConstraintV4>) => void;
/** 컴포넌트 순서 변경 */
onReorderComponent?: (containerId: string, fromIndex: number, toIndex: number) => void;
/** 추가 className */
className?: string;
}
// ========================================
// 컴포넌트 타입별 라벨
// ========================================
const COMPONENT_TYPE_LABELS: Record<PopComponentType, string> = {
"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 (
<div
className={cn("h-full w-full", className)}
onClick={onBackgroundClick}
/>
);
}
return (
<div
className={cn("relative min-h-full w-full bg-white", className)}
onClick={(e) => {
if (e.target === e.currentTarget) {
onBackgroundClick?.();
}
}}
>
{/* 루트 컨테이너 렌더링 */}
<ContainerRenderer
container={root}
components={components}
viewportWidth={viewportWidth}
settings={settings}
isDesignMode={isDesignMode}
selectedComponentId={selectedComponentId}
onComponentClick={onComponentClick}
onContainerClick={onContainerClick}
onComponentResize={onComponentResize}
onReorderComponent={onReorderComponent}
/>
</div>
);
}
// ========================================
// 컨테이너 렌더러 (재귀)
// ========================================
interface ContainerRendererProps {
container: PopContainerV4;
components: Record<string, PopComponentDefinitionV4>;
viewportWidth: number;
settings: PopLayoutDataV4["settings"];
isDesignMode?: boolean;
selectedComponentId?: string | null;
onComponentClick?: (componentId: string) => void;
onContainerClick?: (containerId: string) => void;
onComponentResize?: (componentId: string, size: Partial<PopSizeConstraintV4>) => 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 (
<div
className={cn(
"relative",
isDesignMode && depth > 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 (
<ContainerRenderer
key={child.id}
container={child}
components={components}
viewportWidth={viewportWidth}
settings={settings}
isDesignMode={isDesignMode}
selectedComponentId={selectedComponentId}
onComponentClick={onComponentClick}
onContainerClick={onContainerClick}
onComponentResize={onComponentResize}
onReorderComponent={onReorderComponent}
depth={depth + 1}
/>
);
}
// 컴포넌트 ID인 경우
const componentId = child;
const compDef = components[componentId];
if (!compDef) return null;
// 반응형 숨김 처리
if (compDef.hideBelow && viewportWidth < compDef.hideBelow) {
return null;
}
return (
<DraggableComponentWrapper
key={componentId}
componentId={componentId}
containerId={container.id}
index={index}
isDesignMode={isDesignMode}
onReorder={onReorderComponent}
>
<ComponentRendererV4
componentId={componentId}
component={compDef}
settings={settings}
viewportWidth={viewportWidth}
isDesignMode={isDesignMode}
isSelected={selectedComponentId === componentId}
onClick={() => onComponentClick?.(componentId)}
onResize={onComponentResize}
/>
</DraggableComponentWrapper>
);
})}
</div>
);
}
// ========================================
// 드래그 가능한 컴포넌트 래퍼
// ========================================
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 (
<DraggableComponentWrapperInner
componentId={componentId}
containerId={containerId}
index={index}
onReorder={onReorder}
>
{children}
</DraggableComponentWrapperInner>
);
}
// 디자인 모드 전용 내부 컴포넌트 (DndProvider 필요)
function DraggableComponentWrapperInner({
componentId,
containerId,
index,
onReorder,
children,
}: Omit<DraggableComponentWrapperProps, "isDesignMode">) {
const ref = useRef<HTMLDivElement>(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 (
<div
ref={ref}
className={cn(
"relative",
isDragging && "opacity-50",
isOver && canDrop && "ring-2 ring-blue-500 ring-offset-2"
)}
style={{ cursor: isDragging ? "grabbing" : "grab" }}
>
{children}
{/* 드롭 인디케이터 */}
{isOver && canDrop && (
<div className="absolute inset-0 bg-blue-500/10 pointer-events-none rounded" />
)}
</div>
);
}
// ========================================
// v4 컴포넌트 렌더러 (리사이즈 핸들 포함)
// ========================================
interface ComponentRendererV4Props {
componentId: string;
component: PopComponentDefinitionV4;
settings: PopLayoutDataV4["settings"];
viewportWidth: number;
isDesignMode?: boolean;
isSelected?: boolean;
onClick?: () => void;
onResize?: (componentId: string, size: Partial<PopSizeConstraintV4>) => void;
}
function ComponentRendererV4({
componentId,
component,
settings,
viewportWidth,
isDesignMode = false,
isSelected = false,
onClick,
onResize,
}: ComponentRendererV4Props) {
const { size, alignSelf, type, label } = component;
const containerRef = useRef<HTMLDivElement>(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<PopSizeConstraintV4> = {};
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<PopSizeConstraintV4> = {};
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 (
<div
ref={containerRef}
className={cn(
"relative flex flex-col overflow-visible rounded-lg border-2 bg-white transition-all",
isSelected
? "border-primary ring-2 ring-primary/30 z-10"
: "border-gray-200",
isDesignMode && !isResizing && "cursor-pointer hover:border-gray-300",
isResizing && "select-none"
)}
style={{
...sizeStyle,
...alignStyle,
}}
onClick={(e) => {
e.stopPropagation();
if (!isResizing) {
onClick?.();
}
}}
>
{/* 컴포넌트 라벨 (디자인 모드에서만) */}
{isDesignMode && (
<div
className={cn(
"flex h-5 shrink-0 items-center border-b px-2",
isSelected ? "bg-primary/10" : "bg-gray-50"
)}
>
<span className="text-[10px] font-medium text-gray-600">
{label || typeLabel}
</span>
</div>
)}
{/* 컴포넌트 내용 */}
<div className="flex flex-1 items-center justify-center p-2 overflow-hidden">
{renderComponentContent(component, isDesignMode, settings)}
</div>
{/* 리사이즈 핸들 (디자인 모드 + 선택 시에만) */}
{isDesignMode && isSelected && onResize && (
<>
{/* 오른쪽 핸들 (너비 조정) */}
<div
className="absolute right-0 top-1/2 -translate-y-1/2 translate-x-1/2 w-3 h-8 bg-primary rounded cursor-ew-resize hover:bg-primary/80 flex items-center justify-center z-20"
onMouseDown={(e) => handleResizeStart(e, "width")}
title="너비 조정"
>
<div className="w-0.5 h-4 bg-white rounded" />
</div>
{/* 아래쪽 핸들 (높이 조정) */}
<div
className="absolute bottom-0 left-1/2 -translate-x-1/2 translate-y-1/2 h-3 w-8 bg-primary rounded cursor-ns-resize hover:bg-primary/80 flex items-center justify-center z-20"
onMouseDown={(e) => handleResizeStart(e, "height")}
title="높이 조정"
>
<div className="h-0.5 w-4 bg-white rounded" />
</div>
{/* 오른쪽 아래 핸들 (너비 + 높이 동시 조정) */}
<div
className="absolute right-0 bottom-0 translate-x-1/2 translate-y-1/2 w-4 h-4 bg-primary rounded cursor-nwse-resize hover:bg-primary/80 flex items-center justify-center z-20"
onMouseDown={(e) => handleResizeStart(e, "both")}
title="크기 조정"
>
<div className="w-2 h-2 border-r-2 border-b-2 border-white" />
</div>
</>
)}
</div>
);
}
// ========================================
// 헬퍼 함수들
// ========================================
/**
*
*/
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 (
<div className="h-full w-full flex items-center justify-center border-2 border-dashed border-gray-300 bg-gray-50/50 rounded">
<span className="text-xs text-gray-400"> </span>
</div>
);
}
return (
<div className="text-xs text-gray-400 text-center">
{typeLabel}
</div>
);
}
// 뷰어 모드: 실제 컴포넌트 렌더링
switch (component.type) {
case "pop-field":
return (
<input
type="text"
placeholder={component.label || "입력하세요"}
className="w-full h-full px-3 py-2 text-sm border rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
/>
);
case "pop-button":
return (
<button className="w-full h-full px-4 py-2 text-sm font-medium text-white bg-primary rounded-md hover:bg-primary/90 transition-colors">
{component.label || "버튼"}
</button>
);
case "pop-list":
return (
<div className="w-full h-full overflow-auto p-2">
<div className="text-xs text-gray-500 text-center">
( )
</div>
</div>
);
case "pop-indicator":
return (
<div className="text-center">
<div className="text-2xl font-bold text-primary">0</div>
<div className="text-xs text-gray-500">{component.label || "지표"}</div>
</div>
);
case "pop-scanner":
return (
<div className="text-center text-gray-500">
<div className="text-xs"></div>
<div className="text-[10px]"> </div>
</div>
);
case "pop-numpad":
return (
<div className="grid grid-cols-3 gap-1 p-1 w-full">
{[1, 2, 3, 4, 5, 6, 7, 8, 9, "C", 0, "OK"].map((key) => (
<button
key={key}
className="aspect-square text-xs font-medium bg-gray-100 rounded hover:bg-gray-200"
>
{key}
</button>
))}
</div>
);
case "pop-spacer":
// 실제 모드에서 Spacer는 완전히 투명 (공간만 차지)
return null;
default:
return (
<div className="text-xs text-gray-400">
{typeLabel}
</div>
);
}
}
export default PopFlexRenderer;

View File

@ -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<PopComponentType, PopSizeConstraintV4> = {
"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<PopComponentDefinitionV4>
): 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>
): 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"; // 빈 공간 (레이아웃 정렬용)
// ========================================
// 데이터 흐름

View File

@ -0,0 +1,207 @@
"use client";
import { useState, useCallback, useRef } from "react";
// ========================================
// 레이아웃 히스토리 훅
// Undo/Redo 기능 제공
// ========================================
interface HistoryState<T> {
past: T[];
present: T;
future: T[];
}
interface UseLayoutHistoryReturn<T> {
// 현재 상태
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<T>(
initialState: T,
maxHistory: number = 50
): UseLayoutHistoryReturn<T> {
const [history, setHistory] = useState<HistoryState<T>>({
past: [],
present: initialState,
future: [],
});
// 배치 업데이트를 위한 타이머
const batchTimerRef = useRef<NodeJS.Timeout | null>(null);
const pendingStateRef = useRef<T | null>(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;

529
popdocs/ARCHITECTURE.md Normal file
View File

@ -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 사용
<PopFlexRenderer layout={popLayoutV4} viewportWidth={...} />
} else if (popLayoutV3) {
// v3: PopLayoutRenderer 사용
<PopLayoutV3Renderer layout={popLayoutV3} modeKey={currentModeKey} />
}
```
### `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<PopLayoutDataV3>(...);
const [layoutV4, setLayoutV4] = useState<PopLayoutDataV4>(...);
const [selectedComponentId, setSelectedComponentId] = useState<string | null>(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<PopScreenGroup[]>
// 생성
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<string, GridPosition> };
tablet_portrait: { componentPositions: Record<string, GridPosition> };
mobile_landscape: { componentPositions: Record<string, GridPosition> };
mobile_portrait: { componentPositions: Record<string, GridPosition> };
};
components: Record<string, PopComponentDefinition>;
dataFlow: PopDataFlow;
settings: PopGlobalSettings;
}
```
### v4.0 (신규, 권장)
- **단일 소스** (1번 설계 → 모든 화면 자동 적응)
- **제약 기반** (fixed/fill/hug)
- **Flexbox** 렌더링
- **반응형 규칙** (breakpoint)
```typescript
interface PopLayoutDataV4 {
version: "pop-4.0";
root: PopContainerV4; // 루트 컨테이너 (스택)
components: Record<string, PopComponentDefinitionV4>;
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 화면 시스템의 구조를 이해하고 유지보수하기 위한 참조용으로 작성되었습니다.*

View File

@ -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 전용 캔버스
- 뷰포트 프리셋
- 너비 슬라이더
- 줌/패닝
---

925
popdocs/FILES.md Normal file
View File

@ -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<PopLayoutDataV3 | null>(null);
const [popLayoutV4, setPopLayoutV4] = useState<PopLayoutDataV4 | null>(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 ? (
<PopFlexRenderer layout={popLayoutV4} viewportWidth={...} />
) : popLayoutV3 ? (
<PopLayoutV3Renderer layout={popLayoutV3} modeKey={currentModeKey} />
) : (
// 빈 화면
)}
```
**제공 기능**:
- 반응형 모드 감지 (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<PopLayoutDataV4>(createEmptyPopLayoutV4());
const [selectedComponentId, setSelectedComponentId] = useState<string | null>(null);
const [selectedContainerId, setSelectedContainerId] = useState<string | null>(null);
const [idCounter, setIdCounter] = useState(1);
// 컴포넌트 CRUD
const handleDropComponent = useCallback(...);
const handleDeleteComponent = useCallback(...);
const handleUpdateComponent = useCallback(...);
const handleUpdateContainer = useCallback(...);
return (
<DndProvider backend={HTML5Backend}>
{/* 3-column 레이아웃 */}
<PopPanel />
<PopCanvasV4 ... />
<ComponentEditorPanelV4 ... />
</DndProvider>
);
}
```
---
### `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<ScreenDefinition>) => void;
}
```
**상태 관리**:
```typescript
// 레이아웃 모드
const [layoutMode, setLayoutMode] = useState<"v3" | "v4">("v3");
// v3 레이아웃
const [layoutV3, setLayoutV3] = useState<PopLayoutDataV3>(createEmptyPopLayoutV3());
// v4 레이아웃
const [layoutV4, setLayoutV4] = useState<PopLayoutDataV4>(createEmptyPopLayoutV4());
// 선택 상태
const [selectedComponentId, setSelectedComponentId] = useState<string | null>(null);
const [selectedContainerId, setSelectedContainerId] = useState<string | null>(null);
// UI 상태
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
// v3용 상태
const [activeDevice, setActiveDevice] = useState<DeviceType>("tablet");
const [activeModeKey, setActiveModeKey] = useState<PopLayoutModeKey>("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<PopComponentDefinitionV4>) => void;
onUpdateContainer: (containerId: string, updates: Partial<PopContainerV4>) => 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<PopComponentDefinitionV4>) => void;
onUpdateContainer?: (updates: Partial<PopContainerV4>) => 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<string, PopComponentDefinitionV4>;
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<string, PopComponentDefinition>;
dataFlow: PopDataFlow;
settings: PopGlobalSettings;
metadata?: PopLayoutMetadata;
}
// v3 모드별 레이아웃
interface PopModeLayoutV3 {
componentPositions: Record<string, GridPosition>;
}
// 그리드 위치
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<PopScreenGroup[]>
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<any>;
configPanel?: React.ComponentType<any>;
defaultProps?: Record<string, any>;
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<PopComponentCategory, number>
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<string, any>
function getPopDefaultsByUrl(componentUrl: string): Record<string, any>
function parsePopOverridesByUrl(componentUrl: string, overrides: Record<string, any>): Record<string, any>
```
---
## 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 화면 시스템의 파일 목록을 관리하기 위한 참조용으로 작성되었습니다.*

View File

@ -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 타입 정의 |
---

183
popdocs/STORAGE_RULES.md Normal file
View File

@ -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*

View File

@ -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<string, PopComponentDefinitionV4>;
dataFlow: PopDataFlow;
settings: PopGlobalSettingsV4;
// 모드별 오버라이드 (Phase 2에서 추가)
overrides?: {
mobile_portrait?: ModeOverride;
mobile_landscape?: ModeOverride;
tablet_portrait?: ModeOverride;
// tablet_landscape는 기본이므로 오버라이드 없음
};
}
interface ModeOverride {
components?: Record<string, Partial<PopComponentDefinitionV4>>;
containers?: Record<string, Partial<PopContainerV4>>;
}
```
### 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*

View File

@ -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
역할: 대량 데이터 표시, 선택, 편집