Compare commits

..

No commits in common. "9ebc8c42194130638e3291756538c35cc4b6b91e" and "760e545444fd1e3f146f92ff6f6490baf17152ca" have entirely different histories.

41 changed files with 7004 additions and 7896 deletions

File diff suppressed because it is too large Load Diff

View File

@ -4903,16 +4903,24 @@ export class ScreenManagementService {
companyCode: string,
userId?: string,
): Promise<void> {
console.log(`=== POP 레이아웃 저장 (v5 그리드 시스템) ===`);
console.log(`=== POP 레이아웃 저장 시작 ===`);
console.log(`화면 ID: ${screenId}, 회사: ${companyCode}`);
// v5 그리드 레이아웃만 지원
const componentCount = Object.keys(layoutData.components || {}).length;
console.log(`컴포넌트: ${componentCount}`);
// 버전 감지
const isV3 = layoutData.version === "pop-3.0" ||
(layoutData.layouts && layoutData.components && !layoutData.sections);
const isV2 = layoutData.version === "pop-2.0" ||
(layoutData.layouts && layoutData.sections && layoutData.components);
// v5 형식 검증
if (layoutData.version && layoutData.version !== "pop-5.0") {
console.warn(`레거시 버전 감지 (${layoutData.version}), v5로 변환 필요`);
if (isV3) {
const componentCount = Object.keys(layoutData.components || {}).length;
console.log(`v3 레이아웃: ${componentCount}개 컴포넌트 (섹션 없음)`);
} else if (isV2) {
const sectionCount = Object.keys(layoutData.sections || {}).length;
const componentCount = Object.keys(layoutData.components || {}).length;
console.log(`v2 레이아웃: ${sectionCount}개 섹션, ${componentCount}개 컴포넌트`);
} else {
console.log(`v1 레이아웃 (섹션 수: ${layoutData.sections?.length || 0})`);
}
// 권한 확인
@ -4938,12 +4946,50 @@ export class ScreenManagementService {
console.log(`저장 대상 company_code: ${targetCompanyCode} (사용자: ${companyCode}, 화면: ${existingScreen.company_code})`);
// v5 그리드 레이아웃으로 저장 (단일 버전)
const dataToSave = {
...layoutData,
version: "pop-5.0",
};
console.log(`저장: gridConfig=${JSON.stringify(dataToSave.gridConfig || 'default')}`)
// 버전 정보 보장
let dataToSave: any;
if (isV3) {
dataToSave = {
...layoutData,
version: "pop-3.0",
};
// canvasGrid.rows 검증 및 보정
if (dataToSave.settings?.canvasGrid) {
if (!dataToSave.settings.canvasGrid.rows) {
console.warn("canvasGrid.rows 없음, 기본값 24로 설정");
dataToSave.settings.canvasGrid.rows = 24;
}
// 구버전 rowHeight 필드 제거
if (dataToSave.settings.canvasGrid.rowHeight) {
console.warn("구버전 rowHeight 필드 제거");
delete dataToSave.settings.canvasGrid.rowHeight;
}
}
} else if (isV2) {
dataToSave = {
...layoutData,
version: "pop-2.0",
};
// canvasGrid.rows 검증 및 보정
if (dataToSave.settings?.canvasGrid) {
if (!dataToSave.settings.canvasGrid.rows) {
console.warn("canvasGrid.rows 없음, 기본값 24로 설정");
dataToSave.settings.canvasGrid.rows = 24;
}
if (dataToSave.settings.canvasGrid.rowHeight) {
console.warn("구버전 rowHeight 필드 제거");
delete dataToSave.settings.canvasGrid.rowHeight;
}
}
} else {
// v1 형식으로 저장 (하위 호환)
dataToSave = {
version: "pop-1.0",
...layoutData,
};
}
// UPSERT (있으면 업데이트, 없으면 삽입)
await query(

View File

@ -5,9 +5,11 @@ import { useParams, useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Loader2, ArrowLeft, Smartphone, Tablet, RotateCcw, RotateCw } from "lucide-react";
import { screenApi } from "@/lib/api/screen";
import { ScreenDefinition } from "@/types/screen";
import { ScreenDefinition, LayoutData } from "@/types/screen";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { initializeComponents } from "@/lib/registry/components";
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
import { ScreenPreviewProvider } from "@/contexts/ScreenPreviewContext";
import { useAuth } from "@/hooks/useAuth";
import { TableOptionsProvider } from "@/contexts/TableOptionsContext";
@ -15,14 +17,23 @@ import { TableSearchWidgetHeightProvider } from "@/contexts/TableSearchWidgetHei
import { ScreenContextProvider } from "@/contexts/ScreenContext";
import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout/SplitPanelContext";
import { ActiveTabProvider } from "@/contexts/ActiveTabContext";
import { ScreenMultiLangProvider } from "@/contexts/ScreenMultiLangContext";
import {
PopLayoutDataV5,
GridMode,
isV5Layout,
createEmptyPopLayoutV5,
PopLayoutDataV3,
PopLayoutDataV4,
PopLayoutModeKey,
ensureV3Layout,
isV3Layout,
isV4Layout,
} from "@/components/pop/designer/types/pop-layout";
import PopRenderer from "@/components/pop/designer/renderers/PopRenderer";
import {
PopLayoutRenderer,
hasBaseLayout,
getEffectiveModeLayout,
} from "@/components/pop/designer/renderers";
import { PopFlexRenderer } from "@/components/pop/designer/renderers/PopFlexRenderer";
import {
useResponsiveMode,
useResponsiveModeWithOverride,
type DeviceType,
} from "@/hooks/useDeviceOrientation";
@ -39,16 +50,39 @@ const DEVICE_SIZES: Record<DeviceType, Record<"landscape" | "portrait", { width:
},
};
// 모드 키 변환
const getModeKey = (device: DeviceType, isLandscape: boolean): GridMode => {
// ========================================
// 헬퍼 함수
// ========================================
const getModeKey = (device: DeviceType, isLandscape: boolean): PopLayoutModeKey => {
if (device === "tablet") {
return isLandscape ? "tablet_landscape" : "tablet_portrait";
}
return isLandscape ? "mobile_landscape" : "mobile_portrait";
};
// v3.0 레이아웃인지 확인
const isPopLayoutV3 = (layout: any): layout is PopLayoutDataV3 => {
return layout && layout.version === "pop-3.0" && layout.layouts && layout.components;
};
// 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-4.0"
);
};
// ========================================
// 메인 컴포넌트 (v5 그리드 시스템 전용)
// 메인 컴포넌트
// ========================================
function PopScreenViewPage() {
@ -69,15 +103,21 @@ function PopScreenViewPage() {
// 현재 모드 정보
const deviceType = mode.device;
const isLandscape = mode.isLandscape;
const currentModeKey = getModeKey(deviceType, isLandscape);
const currentModeKey = mode.modeKey;
const { user } = useAuth();
const { user, userName, companyCode } = useAuth();
const [screen, setScreen] = useState<ScreenDefinition | null>(null);
const [layout, setLayout] = useState<PopLayoutDataV5>(createEmptyPopLayoutV5());
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);
const [formData, setFormData] = useState<Record<string, unknown>>({});
const [selectedRowsData, setSelectedRowsData] = useState<any[]>([]);
const [tableRefreshKey, setTableRefreshKey] = useState(0);
// 뷰포트 너비 (클라이언트 사이드에서만 계산, 최대 1366px)
const [viewportWidth, setViewportWidth] = useState(1024); // 기본값: 태블릿 가로
@ -91,6 +131,18 @@ function PopScreenViewPage() {
return () => window.removeEventListener("resize", updateViewportWidth);
}, []);
// 컴포넌트 초기화
useEffect(() => {
const initComponents = async () => {
try {
await initializeComponents();
} catch (error) {
console.error("POP 화면 컴포넌트 초기화 실패:", error);
}
};
initComponents();
}, []);
// 화면 및 POP 레이아웃 로드
useEffect(() => {
const loadScreen = async () => {
@ -104,22 +156,39 @@ function PopScreenViewPage() {
try {
const popLayout = await screenApi.getLayoutPop(screenId);
if (popLayout && isV5Layout(popLayout)) {
// v5 레이아웃 로드
setLayout(popLayout);
if (popLayout && isPopLayoutV4(popLayout)) {
// v4 레이아웃
setPopLayoutV4(popLayout);
setPopLayoutV3(null);
const componentCount = Object.keys(popLayout.components).length;
console.log(`[POP] v5 레이아웃 로드됨: ${componentCount}개 컴포넌트`);
} else if (popLayout) {
// 다른 버전 레이아웃은 빈 v5로 처리
console.log("[POP] 레거시 레이아웃 감지, 빈 레이아웃으로 시작합니다:", popLayout.version);
setLayout(createEmptyPopLayoutV5());
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}개 컴포넌트`);
if (!isV3Layout(popLayout)) {
console.log("[POP] v1/v2 → v3 자동 마이그레이션 완료");
}
} else if (popLayout && popLayout.components && Array.isArray(popLayout.components) && popLayout.components.length > 0) {
// 이전 형식 (레거시 components 구조)
console.log("[POP] 레거시 레이아웃 로드:", popLayout.components.length, "개 컴포넌트");
setLayout(popLayout as LayoutData);
} else {
console.log("[POP] 레이아웃 없음");
setLayout(createEmptyPopLayoutV5());
setPopLayoutV3(null);
setPopLayoutV4(null);
setLayout(null);
}
} catch (layoutError) {
console.warn("[POP] 레이아웃 로드 실패:", layoutError);
setLayout(createEmptyPopLayoutV5());
setPopLayoutV3(null);
setPopLayoutV4(null);
setLayout(null);
}
} catch (error) {
console.error("[POP] 화면 로드 실패:", error);
@ -136,7 +205,6 @@ function PopScreenViewPage() {
}, [screenId]);
const currentDevice = DEVICE_SIZES[deviceType][isLandscape ? "landscape" : "portrait"];
const hasComponents = Object.keys(layout.components).length > 0;
if (loading) {
return (
@ -268,19 +336,70 @@ function PopScreenViewPage() {
flexShrink: 0,
} : undefined}
>
{/* v5 그리드 렌더러 */}
{hasComponents ? (
{/* POP 레이아웃 v4.0 렌더링 */}
{popLayoutV4 ? (
<div
className="mx-auto h-full"
style={{ maxWidth: 1366 }}
>
<PopRenderer
layout={layout}
<PopFlexRenderer
layout={popLayoutV4}
viewportWidth={isPreviewMode ? currentDevice.width : viewportWidth}
currentMode={currentModeKey}
isDesignMode={false}
/>
</div>
) : popLayoutV3 ? (
/* POP 레이아웃 v3.0 렌더링 */
<PopLayoutV3Renderer
layout={popLayoutV3}
modeKey={currentModeKey}
/>
) : layout && layout.components && layout.components.length > 0 ? (
// 레거시 형식 (components 구조) - 호환성 유지
<ScreenMultiLangProvider components={layout.components} companyCode={companyCode}>
<div className="relative w-full min-h-full p-4">
{layout.components
.filter((component) => !component.parentId)
.map((component) => (
<div
key={component.id}
style={{
position: component.position ? "absolute" : "relative",
left: component.position?.x || 0,
top: component.position?.y || 0,
width: component.size?.width || "100%",
height: component.size?.height || "auto",
zIndex: component.position?.z || 1,
}}
>
<DynamicComponentRenderer
component={component}
isDesignMode={false}
isInteractive={true}
formData={formData}
onDataflowComplete={() => { }}
screenId={screenId}
tableName={screen?.tableName}
userId={user?.userId}
userName={userName}
companyCode={companyCode}
selectedRowsData={selectedRowsData}
onSelectedRowsChange={(_, selectedData) => {
setSelectedRowsData(selectedData);
}}
refreshKey={tableRefreshKey}
onRefresh={() => {
setTableRefreshKey((prev) => prev + 1);
setSelectedRowsData([]);
}}
onFormDataChange={(fieldName, value) => {
setFormData((prev) => ({ ...prev, [fieldName]: value }));
}}
/>
</div>
))}
</div>
</ScreenMultiLangProvider>
) : (
// 빈 화면
<div className="flex flex-col items-center justify-center min-h-[400px] p-8 text-center">
@ -304,6 +423,55 @@ function PopScreenViewPage() {
);
}
// ========================================
// POP 레이아웃 v3.0 렌더러
// ========================================
interface PopLayoutV3RendererProps {
layout: PopLayoutDataV3;
modeKey: PopLayoutModeKey;
}
function PopLayoutV3Renderer({ layout, modeKey }: PopLayoutV3RendererProps) {
// 태블릿 가로 모드가 기준으로 설정되어 있는지 확인
if (!hasBaseLayout(layout)) {
return (
<div className="flex flex-col items-center justify-center min-h-[400px] p-8 text-center">
<div className="w-16 h-16 rounded-full bg-yellow-100 flex items-center justify-center mb-4">
<span className="text-2xl">!</span>
</div>
<h3 className="text-lg font-semibold text-gray-800 mb-2">
</h3>
<p className="text-sm text-gray-500 max-w-xs">
POP 릿 .
</p>
</div>
);
}
// 현재 모드에 맞는 레이아웃 가져오기
const { modeLayout, isConverted, sourceModeKey } = getEffectiveModeLayout(layout, modeKey);
return (
<div className="w-full h-full flex flex-col">
{isConverted && (
<div className="mx-2 mt-2 px-2 py-1 bg-yellow-50 border border-yellow-200 rounded text-xs text-yellow-700 shrink-0">
{sourceModeKey}
</div>
)}
<PopLayoutRenderer
layout={layout}
modeKey={modeKey}
customModeLayout={isConverted ? modeLayout : undefined}
isDesignMode={false}
className="flex-1"
style={{ height: "100%" }}
/>
</div>
);
}
// Provider 래퍼
export default function PopScreenViewPageWrapper() {
return (

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

@ -1,130 +1,93 @@
"use client";
import { useCallback, useRef, useState, useEffect, useMemo } from "react";
import { useCallback, useRef, useState, useEffect } from "react";
import { useDrop } from "react-dnd";
import { cn } from "@/lib/utils";
import {
PopLayoutDataV5,
PopComponentDefinitionV5,
PopLayoutDataV3,
PopLayoutModeKey,
PopComponentType,
PopGridPosition,
GridMode,
GRID_BREAKPOINTS,
DEFAULT_COMPONENT_GRID_SIZE,
GridPosition,
MODE_RESOLUTIONS,
} from "./types/pop-layout";
import { ZoomIn, ZoomOut, Maximize2, Smartphone, Tablet } from "lucide-react";
import { DND_ITEM_TYPES, DragItemComponent } from "./panels/PopPanel";
import { ZoomIn, ZoomOut, Maximize2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import PopRenderer from "./renderers/PopRenderer";
import { mouseToGridPosition, findNextEmptyPosition } from "./utils/gridUtils";
// DnD 타입 상수 (인라인)
const DND_ITEM_TYPES = {
COMPONENT: "component",
} as const;
interface DragItemComponent {
type: typeof DND_ITEM_TYPES.COMPONENT;
componentType: PopComponentType;
}
// ========================================
// 프리셋 해상도 (4개 모드)
// 타입 정의
// ========================================
const VIEWPORT_PRESETS = [
{ id: "mobile_portrait", label: "모바일 세로", shortLabel: "모바일↕ (4칸)", width: 375, height: 667, icon: Smartphone },
{ id: "mobile_landscape", label: "모바일 가로", shortLabel: "모바일↔ (6칸)", width: 667, height: 375, icon: Smartphone },
{ id: "tablet_portrait", label: "태블릿 세로", shortLabel: "태블릿↕ (8칸)", width: 768, height: 1024, icon: Tablet },
{ id: "tablet_landscape", label: "태블릿 가로", shortLabel: "태블릿↔ (12칸)", width: 1024, height: 768, icon: Tablet },
] as const;
type DeviceType = "mobile" | "tablet";
type ViewportPreset = GridMode;
// 모드별 라벨
const MODE_LABELS: Record<PopLayoutModeKey, string> = {
tablet_landscape: "태블릿 가로",
tablet_portrait: "태블릿 세로",
mobile_landscape: "모바일 가로",
mobile_portrait: "모바일 세로",
};
// 기본 프리셋 (태블릿 가로)
const DEFAULT_PRESET: ViewportPreset = "tablet_landscape";
// 컴포넌트 타입별 라벨
const COMPONENT_TYPE_LABELS: Record<PopComponentType, string> = {
"pop-field": "필드",
"pop-button": "버튼",
"pop-list": "리스트",
"pop-indicator": "인디케이터",
"pop-scanner": "스캐너",
"pop-numpad": "숫자패드",
};
// ========================================
// Props
// ========================================
interface PopCanvasProps {
layout: PopLayoutDataV5;
layout: PopLayoutDataV3;
activeDevice: DeviceType;
activeModeKey: PopLayoutModeKey;
onModeKeyChange: (modeKey: PopLayoutModeKey) => void;
selectedComponentId: string | null;
currentMode: GridMode;
onModeChange: (mode: GridMode) => void;
onSelectComponent: (id: string | null) => void;
onDropComponent: (type: PopComponentType, position: PopGridPosition) => void;
onUpdateComponent: (componentId: string, updates: Partial<PopComponentDefinitionV5>) => void;
onUpdateComponentPosition: (componentId: string, position: GridPosition, modeKey?: PopLayoutModeKey) => void;
onDropComponent: (type: PopComponentType, gridPosition: GridPosition) => void;
onDeleteComponent: (componentId: string) => void;
onMoveComponent?: (componentId: string, newPosition: PopGridPosition) => void;
onResizeComponent?: (componentId: string, newPosition: PopGridPosition) => void;
}
// ========================================
// PopCanvas: 그리드 캔버스
// 메인 컴포넌트
// ========================================
export default function PopCanvas({
export function PopCanvas({
layout,
activeDevice,
activeModeKey,
onModeKeyChange,
selectedComponentId,
currentMode,
onModeChange,
onSelectComponent,
onUpdateComponentPosition,
onDropComponent,
onUpdateComponent,
onDeleteComponent,
onMoveComponent,
onResizeComponent,
}: PopCanvasProps) {
// 줌 상태
const [canvasScale, setCanvasScale] = useState(0.8);
// 커스텀 뷰포트 크기
const [customWidth, setCustomWidth] = useState(1024);
const [customHeight, setCustomHeight] = useState(768);
// 그리드 가이드 표시 여부
const [showGridGuide, setShowGridGuide] = useState(true);
const { settings, components, layouts } = layout;
const canvasGrid = settings.canvasGrid;
// 줌 상태 (0.3 ~ 1.5 범위)
const [canvasScale, setCanvasScale] = useState(0.6);
// 패닝 상태
const [isPanning, setIsPanning] = useState(false);
const [panStart, setPanStart] = useState({ x: 0, y: 0 });
const [isSpacePressed, setIsSpacePressed] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLDivElement>(null);
// 드래그 상태
const [isDraggingComponent, setIsDraggingComponent] = useState(false);
const [draggedComponentId, setDraggedComponentId] = useState<string | null>(null);
const [dragStartPos, setDragStartPos] = useState<{ x: number; y: number } | null>(null);
const [dragPreviewPos, setDragPreviewPos] = useState<PopGridPosition | null>(null);
// 현재 뷰포트 해상도
const currentPreset = VIEWPORT_PRESETS.find((p) => p.id === currentMode)!;
const breakpoint = GRID_BREAKPOINTS[currentMode];
// 그리드 라벨 계산
const gridLabels = useMemo(() => {
const columnLabels = Array.from({ length: breakpoint.columns }, (_, i) => i + 1);
const rowLabels = Array.from({ length: 20 }, (_, i) => i + 1);
return { columnLabels, rowLabels };
}, [breakpoint.columns]);
// 줌 컨트롤
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 = (mode: GridMode) => {
onModeChange(mode);
const presetData = VIEWPORT_PRESETS.find((p) => p.id === mode)!;
setCustomWidth(presetData.width);
setCustomHeight(presetData.height);
};
// 패닝
const handlePanStart = (e: React.MouseEvent) => {
const isMiddleButton = e.button === 1;
if (isMiddleButton || isSpacePressed) {
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();
@ -142,14 +105,12 @@ export default function PopCanvas({
const handlePanEnd = () => setIsPanning(false);
// Ctrl + 휠로 줌 조정
const handleWheel = (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)));
}
};
// 마우스 휠 줌
const handleWheel = useCallback((e: React.WheelEvent) => {
e.preventDefault();
const delta = e.deltaY > 0 ? -0.1 : 0.1;
setCanvasScale((prev) => Math.max(0.3, Math.min(1.5, prev + delta)));
}, []);
// Space 키 감지
useEffect(() => {
@ -167,139 +128,42 @@ export default function PopCanvas({
};
}, [isSpacePressed]);
// 컴포넌트 드롭 (팔레트에서)
const [{ isOver, canDrop }, drop] = useDrop(
() => ({
accept: DND_ITEM_TYPES.COMPONENT,
drop: (item: DragItemComponent, monitor) => {
if (!canvasRef.current) return;
const offset = monitor.getClientOffset();
if (!offset) return;
const canvasRect = canvasRef.current.getBoundingClientRect();
// 마우스 위치 → 그리드 좌표 변환
const gridPos = mouseToGridPosition(
offset.x,
offset.y,
canvasRect,
breakpoint.columns,
breakpoint.rowHeight,
breakpoint.gap,
breakpoint.padding
);
// 컴포넌트 기본 크기
const defaultSize = DEFAULT_COMPONENT_GRID_SIZE[item.componentType];
// 다음 빈 위치 찾기
const existingPositions = Object.values(layout.components).map(c => c.position);
const position = findNextEmptyPosition(
existingPositions,
defaultSize.colSpan,
defaultSize.rowSpan,
breakpoint.columns
);
// 컴포넌트 추가
onDropComponent(item.componentType, position);
},
collect: (monitor) => ({
isOver: monitor.isOver(),
canDrop: monitor.canDrop(),
}),
}),
[onDropComponent, breakpoint, layout.components]
);
// 초기 로드 시 캔버스 중앙 스크롤
useEffect(() => {
if (containerRef.current) {
const container = containerRef.current;
const timer = setTimeout(() => {
const scrollX = (container.scrollWidth - container.clientWidth) / 2;
const scrollY = (container.scrollHeight - container.clientHeight) / 2;
container.scrollTo(scrollX, scrollY);
}, 100);
return () => clearTimeout(timer);
}
}, [activeDevice]);
drop(canvasRef);
// 빈 상태 체크
const isEmpty = Object.keys(layout.components).length === 0;
// 현재 디바이스의 가로/세로 모드 키
const landscapeModeKey: PopLayoutModeKey = activeDevice === "tablet"
? "tablet_landscape"
: "mobile_landscape";
const portraitModeKey: PopLayoutModeKey = activeDevice === "tablet"
? "tablet_portrait"
: "mobile_portrait";
return (
<div className="flex h-full flex-col bg-gray-50">
{/* 상단 컨트롤 */}
<div className="flex items-center gap-2 border-b bg-white px-4 py-2">
{/* 모드 프리셋 버튼 */}
<div className="flex gap-1">
{VIEWPORT_PRESETS.map((preset) => {
const Icon = preset.icon;
const isActive = currentMode === preset.id;
const isDefault = preset.id === DEFAULT_PRESET;
return (
<Button
key={preset.id}
variant={isActive ? "default" : "outline"}
size="sm"
onClick={() => handleViewportChange(preset.id as GridMode)}
className={cn(
"h-8 gap-1 text-xs",
isActive && "shadow-sm"
)}
>
<Icon className="h-3 w-3" />
{preset.shortLabel}
{isDefault && " (기본)"}
</Button>
);
})}
</div>
<div className="h-4 w-px bg-gray-300" />
{/* 해상도 표시 */}
<div className="text-xs text-muted-foreground">
{customWidth} × {customHeight}
</div>
<div className="flex-1" />
{/* 줌 컨트롤 */}
<div className="flex items-center gap-1">
<span className="text-xs text-muted-foreground">
{Math.round(canvasScale * 100)}%
</span>
<Button
variant="ghost"
size="icon"
onClick={handleZoomOut}
disabled={canvasScale <= 0.3}
className="h-7 w-7"
>
<ZoomOut className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={handleZoomFit}
className="h-7 w-7"
>
<Maximize2 className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={handleZoomIn}
disabled={canvasScale >= 1.5}
className="h-7 w-7"
>
<ZoomIn className="h-3 w-3" />
</Button>
</div>
<div className="h-4 w-px bg-gray-300" />
{/* 그리드 가이드 토글 */}
<Button
variant={showGridGuide ? "default" : "outline"}
size="sm"
onClick={() => setShowGridGuide(!showGridGuide)}
className="h-8 text-xs"
>
{showGridGuide ? "ON" : "OFF"}
<div className="relative flex h-full flex-col bg-gray-50">
{/* 줌 컨트롤 바 */}
<div className="flex shrink-0 items-center justify-end gap-2 border-b bg-white px-4 py-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} title="줌 아웃">
<ZoomOut className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" className="h-7 w-7" onClick={handleZoomIn} title="줌 인">
<ZoomIn className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" className="h-7 w-7" onClick={handleZoomFit} title="맞춤 (100%)">
<Maximize2 className="h-4 w-4" />
</Button>
</div>
@ -307,9 +171,9 @@ export default function PopCanvas({
<div
ref={containerRef}
className={cn(
"canvas-scroll-area relative flex-1 overflow-auto bg-gray-100",
isSpacePressed && "cursor-grab",
isPanning && "cursor-grabbing"
"relative flex-1 overflow-auto",
isPanning && "cursor-grabbing",
isSpacePressed && "cursor-grab"
)}
onMouseDown={handlePanStart}
onMouseMove={handlePanMove}
@ -317,113 +181,380 @@ export default function PopCanvas({
onMouseLeave={handlePanEnd}
onWheel={handleWheel}
>
<div
className="relative mx-auto my-8 origin-top"
style={{
width: `${customWidth + 32}px`, // 라벨 공간 추가
minHeight: `${customHeight + 32}px`,
transform: `scale(${canvasScale})`,
}}
<div
className="canvas-scroll-area flex items-center justify-center gap-16"
style={{ padding: "500px", minWidth: "fit-content", minHeight: "fit-content" }}
>
{/* 그리드 라벨 영역 */}
{showGridGuide && (
<>
{/* 열 라벨 (상단) */}
<div
className="flex absolute top-0 left-8"
style={{
gap: `${breakpoint.gap}px`,
paddingLeft: `${breakpoint.padding}px`,
}}
>
{gridLabels.columnLabels.map((num) => (
<div
key={`col-${num}`}
className="flex items-center justify-center text-xs font-semibold text-blue-500"
style={{
width: `calc((${customWidth}px - ${breakpoint.padding * 2}px - ${breakpoint.gap * (breakpoint.columns - 1)}px) / ${breakpoint.columns})`,
height: "24px",
}}
>
{num}
</div>
))}
</div>
{/* 행 라벨 (좌측) */}
<div
className="flex flex-col absolute top-8 left-0"
style={{
gap: `${breakpoint.gap}px`,
paddingTop: `${breakpoint.padding}px`,
}}
>
{gridLabels.rowLabels.map((num) => (
<div
key={`row-${num}`}
className="flex items-center justify-center text-xs font-semibold text-blue-500"
style={{
width: "24px",
height: `${breakpoint.rowHeight}px`,
}}
>
{num}
</div>
))}
</div>
</>
)}
{/* 가로 모드 */}
<DeviceFrame
modeKey={landscapeModeKey}
isActive={landscapeModeKey === activeModeKey}
scale={canvasScale}
canvasGrid={canvasGrid}
layout={layout}
selectedComponentId={selectedComponentId}
onModeKeyChange={onModeKeyChange}
onSelectComponent={onSelectComponent}
onUpdateComponentPosition={onUpdateComponentPosition}
onDropComponent={onDropComponent}
onDeleteComponent={onDeleteComponent}
/>
{/* 디바이스 스크린 */}
<div
ref={canvasRef}
className={cn(
"relative rounded-lg border-2 bg-white shadow-xl overflow-hidden",
canDrop && isOver && "ring-4 ring-primary/20"
)}
style={{
width: `${customWidth}px`,
minHeight: `${customHeight}px`,
marginLeft: "32px",
marginTop: "32px",
}}
>
{isEmpty ? (
// 빈 상태
<div className="flex h-full items-center justify-center p-8">
<div className="text-center">
<div className="mb-2 text-sm font-medium text-gray-500">
</div>
<div className="text-xs text-gray-400">
{breakpoint.label} - {breakpoint.columns}
</div>
</div>
</div>
) : (
// 그리드 렌더러
<PopRenderer
layout={layout}
viewportWidth={customWidth}
currentMode={currentMode}
isDesignMode={true}
showGridGuide={showGridGuide}
selectedComponentId={selectedComponentId}
onComponentClick={onSelectComponent}
onBackgroundClick={() => onSelectComponent(null)}
/>
)}
</div>
</div>
</div>
{/* 하단 정보 */}
<div className="flex items-center justify-between border-t bg-white px-4 py-2">
<div className="text-xs text-muted-foreground">
{breakpoint.label} - {breakpoint.columns} ( : {breakpoint.rowHeight}px)
</div>
<div className="text-xs text-muted-foreground">
Space + 드래그: 패닝 | Ctrl + :
{/* 세로 모드 */}
<DeviceFrame
modeKey={portraitModeKey}
isActive={portraitModeKey === activeModeKey}
scale={canvasScale}
canvasGrid={canvasGrid}
layout={layout}
selectedComponentId={selectedComponentId}
onModeKeyChange={onModeKeyChange}
onSelectComponent={onSelectComponent}
onUpdateComponentPosition={onUpdateComponentPosition}
onDropComponent={onDropComponent}
onDeleteComponent={onDeleteComponent}
/>
</div>
</div>
</div>
);
}
// ========================================
// CSS Grid 기반 디바이스 프레임 (v3: 컴포넌트 직접 배치)
// ========================================
interface DeviceFrameProps {
modeKey: PopLayoutModeKey;
isActive: boolean;
scale: number;
canvasGrid: { columns: number; rows: number; gap: number };
layout: PopLayoutDataV3;
selectedComponentId: string | null;
onModeKeyChange: (modeKey: PopLayoutModeKey) => void;
onSelectComponent: (id: string | null) => void;
onUpdateComponentPosition: (componentId: string, position: GridPosition, modeKey?: PopLayoutModeKey) => void;
onDropComponent: (type: PopComponentType, gridPosition: GridPosition) => void;
onDeleteComponent: (componentId: string) => void;
}
function DeviceFrame({
modeKey,
isActive,
scale,
canvasGrid,
layout,
selectedComponentId,
onModeKeyChange,
onSelectComponent,
onUpdateComponentPosition,
onDropComponent,
onDeleteComponent,
}: DeviceFrameProps) {
const gridRef = useRef<HTMLDivElement>(null);
const dropRef = useRef<HTMLDivElement>(null);
const { components, layouts } = layout;
const resolution = MODE_RESOLUTIONS[modeKey];
const modeLayout = layouts[modeKey];
const componentPositions = modeLayout.componentPositions;
const componentIds = Object.keys(componentPositions);
const cols = canvasGrid.columns;
const rows = canvasGrid.rows || 24;
const gap = canvasGrid.gap;
// 드래그 상태
const [dragState, setDragState] = useState<{
componentId: string;
startPos: GridPosition;
currentPos: GridPosition;
isDragging: boolean;
} | null>(null);
// 리사이즈 상태
const [resizeState, setResizeState] = useState<{
componentId: string;
startPos: GridPosition;
currentPos: GridPosition;
handle: "se" | "sw" | "ne" | "nw" | "e" | "w" | "n" | "s";
isResizing: boolean;
} | null>(null);
// 라벨
const sizeLabel = `${resolution.width}x${resolution.height}`;
const modeLabel = `${MODE_LABELS[modeKey]} (${sizeLabel})`;
// 마우스 → 그리드 좌표 변환
const getGridPosition = useCallback((clientX: number, clientY: number): { col: number; row: number } => {
if (!gridRef.current) return { col: 1, row: 1 };
const rect = gridRef.current.getBoundingClientRect();
const x = (clientX - rect.left) / scale;
const y = (clientY - rect.top) / scale;
const cellWidth = (resolution.width - gap * (cols + 1)) / cols;
const cellHeight = (resolution.height - gap * (rows + 1)) / rows;
const col = Math.max(1, Math.min(cols, Math.floor((x - gap) / (cellWidth + gap)) + 1));
const row = Math.max(1, Math.min(rows, Math.floor((y - gap) / (cellHeight + gap)) + 1));
return { col, row };
}, [scale, resolution, cols, rows, gap]);
// 드래그 시작
const handleDragStart = useCallback((e: React.MouseEvent, componentId: string) => {
if (!isActive) return;
e.preventDefault();
e.stopPropagation();
const pos = componentPositions[componentId];
setDragState({
componentId,
startPos: { ...pos },
currentPos: { ...pos },
isDragging: true,
});
}, [isActive, componentPositions]);
// 마우스 이동
const handleMouseMove = useCallback((e: React.MouseEvent) => {
if (dragState?.isDragging && gridRef.current) {
const { col, row } = getGridPosition(e.clientX, e.clientY);
const newCol = Math.max(1, Math.min(cols - dragState.startPos.colSpan + 1, col));
const newRow = Math.max(1, Math.min(rows - dragState.startPos.rowSpan + 1, row));
setDragState(prev => prev ? {
...prev,
currentPos: { ...prev.startPos, col: newCol, row: newRow }
} : null);
}
if (resizeState?.isResizing && gridRef.current) {
const { col, row } = getGridPosition(e.clientX, e.clientY);
const startPos = resizeState.startPos;
let newPos = { ...startPos };
switch (resizeState.handle) {
case "se":
newPos.colSpan = Math.max(2, col - startPos.col + 1);
newPos.rowSpan = Math.max(2, row - startPos.row + 1);
break;
case "e":
newPos.colSpan = Math.max(2, col - startPos.col + 1);
break;
case "s":
newPos.rowSpan = Math.max(2, row - startPos.row + 1);
break;
case "sw":
const newColSW = Math.min(col, startPos.col + startPos.colSpan - 2);
newPos.col = newColSW;
newPos.colSpan = startPos.col + startPos.colSpan - newColSW;
newPos.rowSpan = Math.max(2, row - startPos.row + 1);
break;
case "w":
const newColW = Math.min(col, startPos.col + startPos.colSpan - 2);
newPos.col = newColW;
newPos.colSpan = startPos.col + startPos.colSpan - newColW;
break;
case "ne":
const newRowNE = Math.min(row, startPos.row + startPos.rowSpan - 2);
newPos.row = newRowNE;
newPos.rowSpan = startPos.row + startPos.rowSpan - newRowNE;
newPos.colSpan = Math.max(2, col - startPos.col + 1);
break;
case "n":
const newRowN = Math.min(row, startPos.row + startPos.rowSpan - 2);
newPos.row = newRowN;
newPos.rowSpan = startPos.row + startPos.rowSpan - newRowN;
break;
case "nw":
const newColNW = Math.min(col, startPos.col + startPos.colSpan - 2);
const newRowNW = Math.min(row, startPos.row + startPos.rowSpan - 2);
newPos.col = newColNW;
newPos.row = newRowNW;
newPos.colSpan = startPos.col + startPos.colSpan - newColNW;
newPos.rowSpan = startPos.row + startPos.rowSpan - newRowNW;
break;
}
newPos.col = Math.max(1, newPos.col);
newPos.row = Math.max(1, newPos.row);
newPos.colSpan = Math.min(cols - newPos.col + 1, newPos.colSpan);
newPos.rowSpan = Math.min(rows - newPos.row + 1, newPos.rowSpan);
setResizeState(prev => prev ? { ...prev, currentPos: newPos } : null);
}
}, [dragState, resizeState, getGridPosition, cols, rows]);
// 드래그/리사이즈 종료
const handleMouseUp = useCallback(() => {
if (dragState?.isDragging) {
onUpdateComponentPosition(dragState.componentId, dragState.currentPos, modeKey);
setDragState(null);
}
if (resizeState?.isResizing) {
onUpdateComponentPosition(resizeState.componentId, resizeState.currentPos, modeKey);
setResizeState(null);
}
}, [dragState, resizeState, onUpdateComponentPosition, modeKey]);
// 리사이즈 시작
const handleResizeStart = useCallback((e: React.MouseEvent, componentId: string, handle: string) => {
if (!isActive) return;
e.preventDefault();
e.stopPropagation();
const pos = componentPositions[componentId];
setResizeState({
componentId,
startPos: { ...pos },
currentPos: { ...pos },
handle: handle as any,
isResizing: true,
});
}, [isActive, componentPositions]);
// 컴포넌트 드롭
const [{ isOver, canDrop }, drop] = useDrop(
() => ({
accept: DND_ITEM_TYPES.COMPONENT,
drop: (item: DragItemComponent, monitor) => {
if (!isActive) return;
const clientOffset = monitor.getClientOffset();
if (!clientOffset || !gridRef.current) return;
const { col, row } = getGridPosition(clientOffset.x, clientOffset.y);
onDropComponent(item.componentType, { col, row, colSpan: 4, rowSpan: 3 });
},
canDrop: () => isActive,
collect: (monitor) => ({
isOver: monitor.isOver(),
canDrop: monitor.canDrop(),
}),
}),
[isActive, getGridPosition, onDropComponent]
);
drop(dropRef);
// 현재 표시할 위치
const getDisplayPosition = (componentId: string): GridPosition => {
if (dragState?.componentId === componentId && dragState.isDragging) {
return dragState.currentPos;
}
if (resizeState?.componentId === componentId && resizeState.isResizing) {
return resizeState.currentPos;
}
return componentPositions[componentId];
};
return (
<div className="relative shrink-0">
{/* 모드 라벨 */}
<div
className={cn(
"absolute -top-6 left-1/2 -translate-x-1/2 whitespace-nowrap text-xs font-medium",
isActive ? "text-primary" : "text-muted-foreground"
)}
>
{modeLabel}
</div>
{/* 디바이스 프레임 */}
<div
ref={dropRef}
className={cn(
"relative cursor-pointer overflow-hidden rounded-xl bg-white shadow-lg transition-all",
isActive ? "ring-2 ring-primary ring-offset-2" : "ring-1 ring-gray-200 hover:ring-gray-300",
isOver && canDrop && "ring-2 ring-primary bg-primary/5"
)}
style={{
width: resolution.width * scale,
height: resolution.height * scale,
}}
onClick={(e) => {
if (e.target === e.currentTarget) {
if (!isActive) onModeKeyChange(modeKey);
else onSelectComponent(null);
}
}}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
{/* CSS Grid (뷰어와 동일) */}
<div
ref={gridRef}
className="origin-top-left"
style={{
transform: `scale(${scale})`,
width: resolution.width,
height: resolution.height,
display: "grid",
gridTemplateColumns: `repeat(${cols}, 1fr)`,
gridTemplateRows: `repeat(${rows}, 1fr)`,
gap: `${gap}px`,
padding: `${gap}px`,
}}
>
{componentIds.length > 0 ? (
componentIds.map((componentId) => {
const compDef = components[componentId];
if (!compDef) return null;
const pos = getDisplayPosition(componentId);
const isSelected = selectedComponentId === componentId;
const isDragging = dragState?.componentId === componentId && dragState.isDragging;
const isResizing = resizeState?.componentId === componentId && resizeState.isResizing;
return (
<div
key={componentId}
className={cn(
"group relative flex cursor-move items-center justify-center overflow-hidden rounded-lg border-2 bg-white transition-all",
isSelected
? "border-primary ring-2 ring-primary/30"
: "border-gray-200 hover:border-gray-300",
(isDragging || isResizing) && "opacity-80 shadow-xl z-50"
)}
style={{
gridColumn: `${pos.col} / span ${pos.colSpan}`,
gridRow: `${pos.row} / span ${pos.rowSpan}`,
}}
onClick={(e) => {
e.stopPropagation();
if (!isActive) onModeKeyChange(modeKey);
onSelectComponent(componentId);
}}
onMouseDown={(e) => handleDragStart(e, componentId)}
>
{/* 컴포넌트 라벨 */}
<span className="text-xs text-gray-500 select-none">
{compDef.label || COMPONENT_TYPE_LABELS[compDef.type]}
</span>
{/* 리사이즈 핸들 */}
{isActive && isSelected && (
<>
<div className="absolute -right-1 -bottom-1 h-3 w-3 cursor-se-resize rounded-full bg-primary" onMouseDown={(e) => handleResizeStart(e, componentId, "se")} />
<div className="absolute -left-1 -bottom-1 h-3 w-3 cursor-sw-resize rounded-full bg-primary" onMouseDown={(e) => handleResizeStart(e, componentId, "sw")} />
<div className="absolute -right-1 -top-1 h-3 w-3 cursor-ne-resize rounded-full bg-primary" onMouseDown={(e) => handleResizeStart(e, componentId, "ne")} />
<div className="absolute -left-1 -top-1 h-3 w-3 cursor-nw-resize rounded-full bg-primary" onMouseDown={(e) => handleResizeStart(e, componentId, "nw")} />
<div className="absolute right-0 top-1/2 h-6 w-1.5 -translate-y-1/2 cursor-e-resize rounded bg-primary/50" onMouseDown={(e) => handleResizeStart(e, componentId, "e")} />
<div className="absolute left-0 top-1/2 h-6 w-1.5 -translate-y-1/2 cursor-w-resize rounded bg-primary/50" onMouseDown={(e) => handleResizeStart(e, componentId, "w")} />
<div className="absolute bottom-0 left-1/2 h-1.5 w-6 -translate-x-1/2 cursor-s-resize rounded bg-primary/50" onMouseDown={(e) => handleResizeStart(e, componentId, "s")} />
<div className="absolute top-0 left-1/2 h-1.5 w-6 -translate-x-1/2 cursor-n-resize rounded bg-primary/50" onMouseDown={(e) => handleResizeStart(e, componentId, "n")} />
</>
)}
</div>
);
})
) : (
<div
className={cn(
"col-span-full row-span-full flex items-center justify-center text-sm",
isOver && canDrop ? "text-primary" : "text-gray-400"
)}
>
{isOver && canDrop
? "여기에 컴포넌트를 놓으세요"
: isActive
? "왼쪽 패널에서 컴포넌트를 드래그하세요"
: "클릭하여 편집"}
</div>
)}
</div>
</div>
</div>

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,8 +3,9 @@
import { useState, useCallback, useEffect } from "react";
import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
import { ArrowLeft, Save, Undo2, Redo2 } 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 {
ResizableHandle,
ResizablePanel,
@ -12,22 +13,44 @@ import {
} from "@/components/ui/resizable";
import { toast } from "sonner";
import PopCanvas from "./PopCanvas";
import ComponentEditorPanel from "./panels/ComponentEditorPanel";
import ComponentPalette from "./panels/ComponentPalette";
import { PopCanvas } from "./PopCanvas";
import { PopCanvasV4 } from "./PopCanvasV4";
import { PopPanel } from "./panels/PopPanel";
import { ComponentPaletteV4 } from "./panels/ComponentPaletteV4";
import { ComponentEditorPanelV4 } from "./panels/ComponentEditorPanelV4";
import {
PopLayoutDataV5,
PopLayoutDataV3,
PopLayoutDataV4,
PopLayoutModeKey,
PopComponentType,
PopComponentDefinitionV5,
PopGridPosition,
GridMode,
createEmptyPopLayoutV5,
isV5Layout,
addComponentToV5Layout,
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";
// ========================================
// Props
// ========================================
@ -38,7 +61,9 @@ interface PopDesignerProps {
}
// ========================================
// 메인 컴포넌트 (v5 그리드 시스템 전용)
// 메인 컴포넌트 (v3/v4 통합)
// - 새 화면: v4로 시작
// - 기존 v3 화면: v3로 로드 (하위 호환)
// ========================================
export default function PopDesigner({
selectedScreen,
@ -46,76 +71,130 @@ export default function PopDesigner({
onScreenUpdate,
}: PopDesignerProps) {
// ========================================
// 레이아웃 상태
// 레이아웃 모드 (데이터에 따라 자동 결정)
// ========================================
const [layout, setLayout] = useState<PopLayoutDataV5>(createEmptyPopLayoutV5());
const [layoutMode, setLayoutMode] = useState<LayoutMode>("v4");
// 히스토리
const [history, setHistory] = useState<PopLayoutDataV5[]>([]);
const [historyIndex, setHistoryIndex] = useState(-1);
// ========================================
// 레이아웃 상태 (데스크탑 모드와 동일한 방식)
// ========================================
const [layoutV4, setLayoutV4] = useState<PopLayoutDataV4>(createEmptyPopLayoutV4());
const [layoutV3, setLayoutV3] = useState<PopLayoutDataV3>(createEmptyPopLayoutV3());
// UI 상태
// 히스토리 (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 [idCounter, setIdCounter] = useState(1);
// 선택 상태
const [selectedComponentId, setSelectedComponentId] = useState<string | null>(null);
// 그리드 모드 (4개 프리셋)
const [currentMode, setCurrentMode] = useState<GridMode>("tablet_landscape");
// 선택된 컴포넌트
const selectedComponent: PopComponentDefinitionV5 | null = selectedComponentId
? layout.components[selectedComponentId] || null
: null;
// ========================================
// 히스토리 관리
// 히스토리 저장 함수
// ========================================
const saveToHistory = useCallback((newLayout: PopLayoutDataV5) => {
setHistory((prev) => {
const newHistory = prev.slice(0, historyIndex + 1);
newHistory.push(JSON.parse(JSON.stringify(newLayout)));
// 최대 50개 유지
if (newHistory.length > 50) {
newHistory.shift();
return newHistory;
}
return newHistory;
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개
});
setHistoryIndex((prev) => Math.min(prev + 1, 49));
}, [historyIndex]);
setHistoryIndexV4((prev) => Math.min(prev + 1, 49));
}, [historyIndexV4]);
const undo = useCallback(() => {
if (historyIndex > 0) {
const newIndex = historyIndex - 1;
const previousLayout = history[newIndex];
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) {
setLayout(JSON.parse(JSON.stringify(previousLayout)));
setHistoryIndex(newIndex);
setHasChanges(true);
toast.success("실행 취소됨");
setLayoutV4(JSON.parse(JSON.stringify(previousLayout)));
setHistoryIndexV4(newIndex);
console.log("[Undo V4] 복원됨, index:", newIndex);
}
}
}, [historyIndex, history]);
}, [historyIndexV4, historyV4]);
const redo = useCallback(() => {
if (historyIndex < history.length - 1) {
const newIndex = historyIndex + 1;
const nextLayout = history[newIndex];
const redoV4 = useCallback(() => {
if (historyIndexV4 < historyV4.length - 1) {
const newIndex = historyIndexV4 + 1;
const nextLayout = historyV4[newIndex];
if (nextLayout) {
setLayout(JSON.parse(JSON.stringify(nextLayout)));
setHistoryIndex(newIndex);
setHasChanges(true);
toast.success("다시 실행됨");
setLayoutV4(JSON.parse(JSON.stringify(nextLayout)));
setHistoryIndexV4(newIndex);
console.log("[Redo V4] 복원됨, index:", newIndex);
}
}
}, [historyIndex, history]);
}, [historyIndexV4, historyV4]);
const canUndo = historyIndex > 0;
const canRedo = historyIndex < history.length - 1;
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");
// ========================================
// 선택 상태
// ========================================
const [selectedComponentId, setSelectedComponentId] = useState<string | null>(null);
const [selectedContainerId, setSelectedContainerId] = useState<string | null>(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;
// ========================================
// 레이아웃 로드
@ -128,27 +207,45 @@ export default function PopDesigner({
try {
const loadedLayout = await screenApi.getLayoutPop(selectedScreen.screenId);
if (loadedLayout && isV5Layout(loadedLayout) && Object.keys(loadedLayout.components).length > 0) {
// v5 레이아웃 로드
setLayout(loadedLayout);
setHistory([loadedLayout]);
setHistoryIndex(0);
console.log(`POP 레이아웃 로드: ${Object.keys(loadedLayout.components).length}개 컴포넌트`);
// 유효한 레이아웃인지 확인:
// 1. version 필드 필수
// 2. 컴포넌트가 있어야 함 (빈 레이아웃은 새 화면 취급)
const hasValidLayout = loadedLayout && loadedLayout.version;
const hasComponents = loadedLayout?.components && Object.keys(loadedLayout.components).length > 0;
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 {
// 새 화면 또는 빈 레이아웃
const emptyLayout = createEmptyPopLayoutV5();
setLayout(emptyLayout);
setHistory([emptyLayout]);
setHistoryIndex(0);
console.log("새 POP 화면 생성 (v5 그리드)");
// 새 화면 또는 빈 레이아웃 → v4로 시작
const emptyLayout = createEmptyPopLayoutV4();
setLayoutV4(emptyLayout);
setHistoryV4([emptyLayout]);
setHistoryIndexV4(0);
setLayoutMode("v4");
}
} catch (error) {
console.error("레이아웃 로드 실패:", error);
toast.error("레이아웃을 불러오는데 실패했습니다");
const emptyLayout = createEmptyPopLayoutV5();
setLayout(emptyLayout);
setHistory([emptyLayout]);
setHistoryIndex(0);
const emptyLayout = createEmptyPopLayoutV4();
setLayoutV4(emptyLayout);
setHistoryV4([emptyLayout]);
setHistoryIndexV4(0);
setLayoutMode("v4");
} finally {
setIsLoading(false);
}
@ -165,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) {
@ -174,69 +272,190 @@ export default function PopDesigner({
} finally {
setIsSaving(false);
}
}, [selectedScreen?.screenId, layout]);
}, [selectedScreen?.screenId, layoutMode, layoutV3, layoutV4]);
// ========================================
// 컴포넌트 핸들러
// v3: 컴포넌트 핸들러
// ========================================
const handleDropComponent = useCallback(
(type: PopComponentType, position: PopGridPosition) => {
const componentId = `comp_${idCounter}`;
setIdCounter((prev) => prev + 1);
const newLayout = addComponentToV5Layout(layout, componentId, type, position, `${type} ${idCounter}`);
setLayout(newLayout);
saveToHistory(newLayout);
setSelectedComponentId(componentId);
const handleDropComponentV3 = useCallback(
(type: PopComponentType, gridPosition: GridPosition) => {
const newId = `${type}-${Date.now()}`;
const newLayout = addComponentToV3Layout(layoutV3, newId, type, gridPosition);
setLayoutV3(newLayout);
saveToHistoryV3(newLayout);
setSelectedComponentId(newId);
setHasChanges(true);
},
[idCounter, layout, saveToHistory]
[layoutV3, saveToHistoryV3]
);
const handleUpdateComponent = useCallback(
(componentId: string, updates: Partial<PopComponentDefinitionV5>) => {
const existingComponent = layout.components[componentId];
const handleUpdateComponentDefinitionV3 = useCallback(
(componentId: string, updates: Partial<PopComponentDefinition>) => {
const newLayout = {
...layoutV3,
components: {
...layoutV3.components,
[componentId]: { ...layoutV3.components[componentId], ...updates },
},
};
setLayoutV3(newLayout);
saveToHistoryV3(newLayout);
setHasChanges(true);
},
[layoutV3, saveToHistoryV3]
);
const handleUpdateComponentPositionV3 = useCallback(
(componentId: string, position: GridPosition, modeKey?: PopLayoutModeKey) => {
const targetMode = modeKey || activeModeKey;
const newLayout = updateComponentPositionInModeV3(layoutV3, targetMode, componentId, position);
setLayoutV3(newLayout);
saveToHistoryV3(newLayout);
setHasChanges(true);
},
[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 = {
...layout,
...layoutV4,
components: {
...layout.components,
...layoutV4.components,
[componentId]: {
...existingComponent,
...updates,
size: {
...existingComponent.size,
...sizeUpdates,
},
},
},
};
setLayout(newLayout);
saveToHistory(newLayout);
setLayoutV4(newLayout);
// 리사이즈 중에는 히스토리 저장 안 함 (너무 많아짐)
// saveToHistoryV4(newLayout);
setHasChanges(true);
},
[layout, saveToHistory]
[layoutV4]
);
const handleDeleteComponent = useCallback(
(componentId: string) => {
const newComponents = { ...layout.components };
delete newComponents[componentId];
const newLayout = {
...layout,
components: newComponents,
// 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;
}),
};
};
setLayout(newLayout);
saveToHistory(newLayout);
setSelectedComponentId(null);
const newLayout = {
...layoutV4,
root: reorderInContainer(layoutV4.root),
};
setLayoutV4(newLayout);
saveToHistoryV4(newLayout);
setHasChanges(true);
console.log("[V4] 컴포넌트 순서 변경", { containerId, fromIndex, toIndex });
},
[layout, saveToHistory]
[layoutV4, saveToHistoryV4]
);
// ========================================
// v3: 디바이스/모드 전환
// ========================================
const handleDeviceChange = useCallback((device: DeviceType) => {
setActiveDevice(device);
setActiveModeKey(device === "tablet" ? "tablet_landscape" : "mobile_landscape");
}, []);
const handleModeKeyChange = useCallback((modeKey: PopLayoutModeKey) => {
setActiveModeKey(modeKey);
}, []);
// ========================================
// 뒤로가기
// ========================================
const handleBack = useCallback(() => {
if (hasChanges) {
if (confirm("저장하지 않은 변경사항이 있습니다. 정말 나가시겠습니까?")) {
if (confirm("저장하지 않은 변경사항이 있습니다. 나가시겠습니까?")) {
onBackToList();
}
} else {
@ -245,7 +464,7 @@ export default function PopDesigner({
}, [hasChanges, onBackToList]);
// ========================================
// 단축키 처리
// 단축키 처리 (Delete, Undo, Redo)
// ========================================
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@ -261,35 +480,53 @@ export default function PopDesigner({
if (e.key === "Delete" || e.key === "Backspace") {
e.preventDefault();
if (selectedComponentId) {
handleDeleteComponent(selectedComponentId);
layoutMode === "v3" ? handleDeleteComponentV3(selectedComponentId) : handleDeleteComponentV4(selectedComponentId);
}
}
// Ctrl+Z: Undo
// Ctrl+Z / Cmd+Z: Undo (Shift 안 눌림)
if (isCtrlOrCmd && key === "z" && !e.shiftKey) {
e.preventDefault();
if (canUndo) undo();
console.log("Undo 시도:", { canUndo, layoutMode });
if (canUndo) {
handleUndo();
setHasChanges(true);
toast.success("실행 취소됨");
} else {
toast.info("실행 취소할 내용이 없습니다");
}
return;
}
// Ctrl+Shift+Z or Ctrl+Y: Redo
if ((isCtrlOrCmd && key === "z" && e.shiftKey) || (isCtrlOrCmd && key === "y")) {
// Ctrl+Shift+Z / Cmd+Shift+Z: Redo
if (isCtrlOrCmd && key === "z" && e.shiftKey) {
e.preventDefault();
if (canRedo) redo();
console.log("Redo 시도:", { canRedo, layoutMode });
if (canRedo) {
handleRedo();
setHasChanges(true);
toast.success("다시 실행됨");
} else {
toast.info("다시 실행할 내용이 없습니다");
}
return;
}
// Ctrl+S: 저장
if (isCtrlOrCmd && key === "s") {
// Ctrl+Y / Cmd+Y: Redo (대체)
if (isCtrlOrCmd && key === "y") {
e.preventDefault();
handleSave();
if (canRedo) {
handleRedo();
setHasChanges(true);
toast.success("다시 실행됨");
}
return;
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [selectedComponentId, handleDeleteComponent, canUndo, canRedo, undo, redo, handleSave]);
}, [selectedComponentId, layoutMode, handleDeleteComponentV3, handleDeleteComponentV4, canUndo, canRedo, handleUndo, handleRedo]);
// ========================================
// 로딩
@ -307,25 +544,41 @@ export default function PopDesigner({
// ========================================
return (
<DndProvider backend={HTML5Backend}>
<div className="flex h-screen flex-col">
{/* 헤더 */}
<div className="flex items-center justify-between border-b bg-white px-4 py-2">
<div className="flex h-screen flex-col bg-background">
{/* 툴바 */}
<div className="flex h-12 items-center justify-between border-b px-4">
{/* 왼쪽: 뒤로가기 + 화면명 */}
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="icon"
onClick={handleBack}
className="h-8 w-8"
>
<ArrowLeft className="h-4 w-4" />
<Button variant="ghost" size="sm" onClick={handleBack}>
<ArrowLeft className="mr-1 h-4 w-4" />
</Button>
<div>
<h2 className="text-sm font-medium">{selectedScreen?.screenName}</h2>
<p className="text-xs text-muted-foreground">
(v5)
</p>
</div>
<span className="text-sm font-medium">
{selectedScreen?.screenName || "POP 화면"}
</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 + 저장 */}
@ -336,7 +589,11 @@ export default function PopDesigner({
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={undo}
onClick={() => {
handleUndo();
setHasChanges(true);
toast.success("실행 취소됨");
}}
disabled={!canUndo}
title="실행 취소 (Ctrl+Z)"
>
@ -346,7 +603,11 @@ export default function PopDesigner({
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={redo}
onClick={() => {
handleRedo();
setHasChanges(true);
toast.success("다시 실행됨");
}}
disabled={!canRedo}
title="다시 실행 (Ctrl+Shift+Z)"
>
@ -364,41 +625,78 @@ export default function PopDesigner({
{/* 메인 영역 */}
<ResizablePanelGroup direction="horizontal" className="flex-1">
{/* 왼쪽: 컴포넌트 팔레트 */}
<ResizablePanel defaultSize={15} minSize={12} maxSize={20}>
<ComponentPalette />
{/* 왼쪽: 컴포넌트 패널 */}
<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={65}>
<PopCanvas
layout={layout}
selectedComponentId={selectedComponentId}
currentMode={currentMode}
onModeChange={setCurrentMode}
onSelectComponent={setSelectedComponentId}
onDropComponent={handleDropComponent}
onUpdateComponent={handleUpdateComponent}
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>
<ResizableHandle withHandle />
{/* 오른쪽: 속성 패널 */}
<ResizablePanel defaultSize={20} minSize={15} maxSize={30}>
<ComponentEditorPanel
component={selectedComponent}
currentMode={currentMode}
onUpdateComponent={
selectedComponentId
? (updates) => handleUpdateComponent(selectedComponentId, updates)
: undefined
}
/>
</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

@ -1,4 +1,4 @@
// POP 디자이너 컴포넌트 export (v5 그리드 시스템)
// POP 디자이너 컴포넌트 export
// 타입
export * from "./types";
@ -7,25 +7,18 @@ export * from "./types";
export { default as PopDesigner } from "./PopDesigner";
// 캔버스
export { default as PopCanvas } from "./PopCanvas";
export { PopCanvas } from "./PopCanvas";
// 패널
export { default as ComponentEditorPanel } from "./panels/ComponentEditorPanel";
export { PopPanel } from "./panels/PopPanel";
// 렌더러
export { default as PopRenderer } from "./renderers/PopRenderer";
// 유틸리티
export * from "./utils/gridUtils";
// 핵심 타입 재export (편의)
// 타입 재export (편의)
export type {
PopLayoutDataV5,
PopComponentDefinitionV5,
PopLayoutData,
PopSectionData,
PopComponentData,
PopComponentType,
PopGridPosition,
GridMode,
PopGridConfig,
PopDataBinding,
PopDataFlow,
GridPosition,
PopCanvasGrid,
PopInnerGrid,
} from "./types/pop-layout";

View File

@ -2,73 +2,50 @@
import React from "react";
import { cn } from "@/lib/utils";
import {
PopComponentDefinitionV5,
PopGridPosition,
GridMode,
GRID_BREAKPOINTS,
PopComponentType,
} from "../types/pop-layout";
import {
Settings,
Database,
Eye,
Grid3x3,
MoveHorizontal,
MoveVertical,
} from "lucide-react";
import { PopComponentDefinition, PopComponentConfig } from "../types/pop-layout";
import { Settings, Database, Link2 } from "lucide-react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
// ========================================
// Props
// Props 정의
// ========================================
interface ComponentEditorPanelProps {
/** 선택된 컴포넌트 */
component: PopComponentDefinitionV5 | null;
/** 현재 모드 */
currentMode: GridMode;
/** 컴포넌트 업데이트 */
onUpdateComponent?: (updates: Partial<PopComponentDefinitionV5>) => void;
/** 선택된 컴포넌트 (없으면 null) */
component: PopComponentDefinition | null;
/** 컴포넌트 설정 변경 시 호출 */
onConfigChange?: (config: Partial<PopComponentConfig>) => void;
/** 컴포넌트 라벨 변경 시 호출 */
onLabelChange?: (label: string) => 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": "스페이서",
"pop-break": "줄바꿈",
};
// ========================================
// 컴포넌트 편집 패널 (v5 그리드 시스템)
// 컴포넌트 편집 패널
//
// 역할:
// - 선택된 컴포넌트의 설정을 편집
// - 3개 탭: 기본 설정 / 데이터 바인딩 / 데이터 연결
//
// TODO:
// - 타입별 상세 설정 UI 구현
// - 데이터 바인딩 UI 구현
// - 데이터 플로우 UI 구현
// ========================================
export default function ComponentEditorPanel({
export function ComponentEditorPanel({
component,
currentMode,
onUpdateComponent,
onConfigChange,
onLabelChange,
className,
}: ComponentEditorPanelProps) {
const breakpoint = GRID_BREAKPOINTS[currentMode];
// 선택된 컴포넌트 없음
// 컴포넌트가 선택되지 않은 경우
if (!component) {
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>
<h3 className="text-sm font-medium"> </h3>
</div>
<div className="flex flex-1 items-center justify-center p-4 text-sm text-muted-foreground">
@ -77,262 +54,92 @@ export default function ComponentEditorPanel({
);
}
// 기본 모드 여부
const isDefaultMode = currentMode === "tablet_landscape";
return (
<div className={cn("flex h-full flex-col bg-white", className)}>
<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]}
{component.label || getComponentTypeLabel(component.type)}
</h3>
<p className="text-xs text-muted-foreground">{component.type}</p>
{!isDefaultMode && (
<p className="text-xs text-amber-600 mt-1">
(릿 )
</p>
)}
</div>
{/* 탭 */}
<Tabs defaultValue="position" className="flex flex-1 flex-col">
{/* 탭 컨텐츠 */}
<Tabs defaultValue="settings" className="flex-1">
<TabsList className="w-full justify-start rounded-none border-b bg-transparent px-2">
<TabsTrigger value="position" className="gap-1 text-xs">
<Grid3x3 className="h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="settings" className="gap-1 text-xs">
<Settings className="h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="visibility" className="gap-1 text-xs">
<Eye className="h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="data" className="gap-1 text-xs">
<Database className="h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="flow" className="gap-1 text-xs">
<Link2 className="h-3 w-3" />
</TabsTrigger>
</TabsList>
{/* 위치 탭 */}
<TabsContent value="position" className="flex-1 overflow-auto p-4">
<PositionForm
component={component}
currentMode={currentMode}
isDefaultMode={isDefaultMode}
columns={breakpoint.columns}
onUpdate={onUpdateComponent}
/>
</TabsContent>
{/* 설정 탭 */}
{/* 기본 설정 탭 */}
<TabsContent value="settings" className="flex-1 overflow-auto p-4">
<ComponentSettingsForm
component={component}
onUpdate={onUpdateComponent}
onConfigChange={onConfigChange}
onLabelChange={onLabelChange}
/>
</TabsContent>
{/* 표시 탭 */}
<TabsContent value="visibility" className="flex-1 overflow-auto p-4">
<VisibilityForm
component={component}
onUpdate={onUpdateComponent}
/>
</TabsContent>
{/* 데이터 탭 */}
{/* 데이터 바인딩 탭 (뼈대) */}
<TabsContent value="data" className="flex-1 overflow-auto p-4">
<DataBindingPlaceholder />
</TabsContent>
{/* 데이터 연결 탭 (뼈대) */}
<TabsContent value="flow" className="flex-1 overflow-auto p-4">
<DataFlowPlaceholder />
</TabsContent>
</Tabs>
</div>
);
}
// ========================================
// 위치 편집 폼
// ========================================
interface PositionFormProps {
component: PopComponentDefinitionV5;
currentMode: GridMode;
isDefaultMode: boolean;
columns: number;
onUpdate?: (updates: Partial<PopComponentDefinitionV5>) => void;
}
function PositionForm({ component, currentMode, isDefaultMode, columns, onUpdate }: PositionFormProps) {
const { position } = component;
const handlePositionChange = (field: keyof PopGridPosition, value: number) => {
// 범위 체크
let clampedValue = Math.max(1, value);
if (field === "col" || field === "colSpan") {
clampedValue = Math.min(columns, clampedValue);
}
if (field === "colSpan" && position.col + clampedValue - 1 > columns) {
clampedValue = columns - position.col + 1;
}
onUpdate?.({
position: {
...position,
[field]: clampedValue,
},
});
};
return (
<div className="space-y-6">
{/* 그리드 정보 */}
<div className="rounded-lg bg-gray-50 p-3">
<p className="text-xs font-medium text-gray-700 mb-1">
: {GRID_BREAKPOINTS[currentMode].label}
</p>
<p className="text-xs text-muted-foreground">
{columns} ×
</p>
</div>
{/* 열 위치 */}
<div className="space-y-2">
<Label className="text-xs font-medium flex items-center gap-1">
<MoveHorizontal className="h-3 w-3" />
(Col)
</Label>
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
max={columns}
value={position.col}
onChange={(e) => handlePositionChange("col", parseInt(e.target.value) || 1)}
disabled={!isDefaultMode}
className="h-8 w-20 text-xs"
/>
<span className="text-xs text-muted-foreground">
(1~{columns})
</span>
</div>
</div>
{/* 행 위치 */}
<div className="space-y-2">
<Label className="text-xs font-medium flex items-center gap-1">
<MoveVertical className="h-3 w-3" />
(Row)
</Label>
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
value={position.row}
onChange={(e) => handlePositionChange("row", parseInt(e.target.value) || 1)}
disabled={!isDefaultMode}
className="h-8 w-20 text-xs"
/>
<span className="text-xs text-muted-foreground">
(1~)
</span>
</div>
</div>
<div className="h-px bg-gray-200" />
{/* 열 크기 */}
<div className="space-y-2">
<Label className="text-xs font-medium flex items-center gap-1">
<MoveHorizontal className="h-3 w-3" />
(ColSpan)
</Label>
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
max={columns}
value={position.colSpan}
onChange={(e) => handlePositionChange("colSpan", parseInt(e.target.value) || 1)}
disabled={!isDefaultMode}
className="h-8 w-20 text-xs"
/>
<span className="text-xs text-muted-foreground">
(1~{columns})
</span>
</div>
<p className="text-xs text-muted-foreground">
{Math.round((position.colSpan / columns) * 100)}%
</p>
</div>
{/* 행 크기 */}
<div className="space-y-2">
<Label className="text-xs font-medium flex items-center gap-1">
<MoveVertical className="h-3 w-3" />
(RowSpan)
</Label>
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
value={position.rowSpan}
onChange={(e) => handlePositionChange("rowSpan", parseInt(e.target.value) || 1)}
disabled={!isDefaultMode}
className="h-8 w-20 text-xs"
/>
<span className="text-xs text-muted-foreground">
</span>
</div>
<p className="text-xs text-muted-foreground">
: {position.rowSpan * GRID_BREAKPOINTS[currentMode].rowHeight}px
</p>
</div>
{/* 비활성화 안내 */}
{!isDefaultMode && (
<div className="rounded-lg bg-amber-50 border border-amber-200 p-3">
<p className="text-xs text-amber-800">
(릿 ) .
.
</p>
</div>
)}
</div>
);
}
// ========================================
// 설정 폼
// 컴포넌트 설정 폼
// ========================================
interface ComponentSettingsFormProps {
component: PopComponentDefinitionV5;
onUpdate?: (updates: Partial<PopComponentDefinitionV5>) => void;
component: PopComponentDefinition;
onConfigChange?: (config: Partial<PopComponentConfig>) => void;
onLabelChange?: (label: string) => void;
}
function ComponentSettingsForm({ component, onUpdate }: ComponentSettingsFormProps) {
function ComponentSettingsForm({
component,
onConfigChange,
onLabelChange,
}: ComponentSettingsFormProps) {
return (
<div className="space-y-4">
{/* 라벨 */}
<div className="space-y-2">
<Label className="text-xs font-medium"></Label>
<Input
{/* 라벨 입력 */}
<div className="space-y-1.5">
<label className="text-xs font-medium"></label>
<input
type="text"
className="h-8 w-full rounded border border-input bg-background px-2 text-sm"
value={component.label || ""}
onChange={(e) => onUpdate?.({ label: e.target.value })}
placeholder="컴포넌트 이름"
className="h-8 text-xs"
onChange={(e) => onLabelChange?.(e.target.value)}
placeholder="컴포넌트 라벨"
/>
</div>
{/* 컴포넌트 타입별 설정 (추후 구현) */}
<div className="rounded-lg bg-gray-50 p-3">
<p className="text-xs text-muted-foreground">
{component.type} Phase 4
{/* 타입별 설정 (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">
{getComponentTypeLabel(component.type)}
</p>
<p className="mt-1 text-center text-xs text-muted-foreground">
( )
</p>
</div>
</div>
@ -340,82 +147,69 @@ function ComponentSettingsForm({ component, onUpdate }: ComponentSettingsFormPro
}
// ========================================
// 표시/숨김 폼
// ========================================
interface VisibilityFormProps {
component: PopComponentDefinitionV5;
onUpdate?: (updates: Partial<PopComponentDefinitionV5>) => void;
}
function VisibilityForm({ component, onUpdate }: VisibilityFormProps) {
const modes: Array<{ key: GridMode; label: string }> = [
{ key: "tablet_landscape", label: "태블릿 가로 (12칸)" },
{ key: "tablet_portrait", label: "태블릿 세로 (8칸)" },
{ key: "mobile_landscape", label: "모바일 가로 (6칸)" },
{ key: "mobile_portrait", label: "모바일 세로 (4칸)" },
];
const handleVisibilityChange = (mode: GridMode, visible: boolean) => {
onUpdate?.({
visibility: {
...component.visibility,
[mode]: visible,
},
});
};
return (
<div className="space-y-4">
<div className="space-y-3">
<Label className="text-xs font-medium"> </Label>
{modes.map((mode) => {
const isVisible = component.visibility?.[mode.key] !== false;
return (
<div key={mode.key} className="flex items-center gap-2">
<Checkbox
id={`visibility-${mode.key}`}
checked={isVisible}
onCheckedChange={(checked) =>
handleVisibilityChange(mode.key, checked === true)
}
/>
<label
htmlFor={`visibility-${mode.key}`}
className="text-xs cursor-pointer"
>
{mode.label}
</label>
</div>
);
})}
</div>
<div className="rounded-lg bg-blue-50 border border-blue-200 p-3">
<p className="text-xs text-blue-800">
</p>
</div>
</div>
);
}
// ========================================
// 데이터 바인딩 플레이스홀더
// 데이터 바인딩 플레이스홀더 (뼈대)
// ========================================
function DataBindingPlaceholder() {
return (
<div className="space-y-4">
<div className="rounded-lg bg-gray-50 p-4 text-center">
<Database className="mx-auto mb-2 h-8 w-8 text-muted-foreground" />
<p className="text-sm font-medium text-gray-700"> </p>
<p className="text-xs text-muted-foreground mt-1">
Phase 4
</p>
<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>
);
}
// ========================================
// 데이터 플로우 플레이스홀더 (뼈대)
// ========================================
function DataFlowPlaceholder() {
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">
<Link2 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>
);
}
// ========================================
// 헬퍼 함수
// ========================================
function getComponentTypeLabel(type: string): string {
const labels: Record<string, string> = {
"pop-field": "필드",
"pop-button": "버튼",
"pop-list": "리스트",
"pop-indicator": "인디케이터",
"pop-scanner": "스캐너",
"pop-numpad": "넘패드",
};
return labels[type] || type;
}
export default ComponentEditorPanel;

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

@ -1,96 +0,0 @@
"use client";
import { useDrag } from "react-dnd";
import { cn } from "@/lib/utils";
import { PopComponentType } from "../types/pop-layout";
import { Square } from "lucide-react";
// DnD 타입 상수
const DND_ITEM_TYPES = {
COMPONENT: "component",
} as const;
// 컴포넌트 정의
interface PaletteItem {
type: PopComponentType;
label: string;
icon: React.ElementType;
description: string;
}
const PALETTE_ITEMS: PaletteItem[] = [
{
type: "pop-sample",
label: "샘플 박스",
icon: Square,
description: "크기 조정 테스트용",
},
];
// 드래그 가능한 컴포넌트 아이템
function DraggablePaletteItem({ item }: { item: PaletteItem }) {
const [{ isDragging }, drag] = useDrag(
() => ({
type: DND_ITEM_TYPES.COMPONENT,
item: { type: DND_ITEM_TYPES.COMPONENT, componentType: item.type },
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
}),
[item.type]
);
const Icon = item.icon;
return (
<div
ref={drag}
className={cn(
"flex cursor-grab items-center gap-3 rounded-md border bg-white p-3",
"transition-all hover:border-primary hover:shadow-sm",
isDragging && "opacity-50 cursor-grabbing"
)}
>
<div className="flex h-9 w-9 items-center justify-center rounded bg-muted">
<Icon className="h-4 w-4 text-muted-foreground" />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium">{item.label}</div>
<div className="text-xs text-muted-foreground truncate">
{item.description}
</div>
</div>
</div>
);
}
// 컴포넌트 팔레트 패널
export default function ComponentPalette() {
return (
<div className="flex h-full flex-col bg-gray-50">
{/* 헤더 */}
<div className="border-b bg-white px-4 py-3">
<h3 className="text-sm font-semibold"></h3>
<p className="text-xs text-muted-foreground">
</p>
</div>
{/* 컴포넌트 목록 */}
<div className="flex-1 overflow-y-auto p-3">
<div className="space-y-2">
{PALETTE_ITEMS.map((item) => (
<DraggablePaletteItem key={item.type} item={item} />
))}
</div>
</div>
{/* 하단 안내 */}
<div className="border-t bg-white px-4 py-3">
<p className="text-xs text-muted-foreground">
Tip: 캔버스의
</p>
</div>
</div>
);
}

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

@ -0,0 +1,368 @@
"use client";
import { useState } from "react";
import { useDrag } from "react-dnd";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Plus,
Settings,
Type,
MousePointer,
List,
Activity,
ScanLine,
Calculator,
Trash2,
ChevronDown,
GripVertical,
} from "lucide-react";
import { cn } from "@/lib/utils";
import {
PopLayoutDataV3,
PopLayoutModeKey,
PopComponentDefinition,
PopComponentType,
MODE_RESOLUTIONS,
} from "../types/pop-layout";
// ========================================
// 드래그 아이템 타입
// ========================================
export const DND_ITEM_TYPES = {
COMPONENT: "component",
} as const;
export interface DragItemComponent {
type: typeof DND_ITEM_TYPES.COMPONENT;
componentType: PopComponentType;
}
// ========================================
// 컴포넌트 팔레트 정의
// ========================================
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: "숫자 입력 전용",
},
];
// ========================================
// Props (v3: 섹션 없음)
// ========================================
interface PopPanelProps {
layout: PopLayoutDataV3;
activeModeKey: PopLayoutModeKey;
selectedComponentId: string | null;
selectedComponent: PopComponentDefinition | null;
onUpdateComponentDefinition: (id: string, updates: Partial<PopComponentDefinition>) => void;
onDeleteComponent: (id: string) => void;
activeDevice: "mobile" | "tablet";
}
// ========================================
// 메인 컴포넌트
// ========================================
export function PopPanel({
layout,
activeModeKey,
selectedComponentId,
selectedComponent,
onUpdateComponentDefinition,
onDeleteComponent,
activeDevice,
}: PopPanelProps) {
const [activeTab, setActiveTab] = useState<string>("components");
// 현재 모드의 컴포넌트 위치
const currentModeLayout = layout.layouts[activeModeKey];
const selectedComponentPosition = selectedComponentId
? currentModeLayout.componentPositions[selectedComponentId]
: null;
return (
<div className="flex h-full flex-col">
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="flex h-full flex-col"
>
<TabsList className="mx-2 mt-2 grid w-auto grid-cols-2">
<TabsTrigger value="components" className="text-xs">
<Plus className="mr-1 h-3.5 w-3.5" />
</TabsTrigger>
<TabsTrigger value="edit" className="text-xs">
<Settings className="mr-1 h-3.5 w-3.5" />
</TabsTrigger>
</TabsList>
{/* 컴포넌트 탭 */}
<TabsContent value="components" className="flex-1 overflow-auto p-2">
<div className="space-y-4">
{/* 현재 모드 표시 */}
<div className="rounded-lg bg-muted p-2">
<p className="text-xs font-medium text-muted-foreground">
: {getModeLabel(activeModeKey)}
</p>
<p className="text-[10px] text-muted-foreground">
{MODE_RESOLUTIONS[activeModeKey].width} x {MODE_RESOLUTIONS[activeModeKey].height}
</p>
</div>
{/* 컴포넌트 팔레트 */}
<div>
<h4 className="mb-2 text-xs font-medium text-muted-foreground">
</h4>
<div className="space-y-1">
{COMPONENT_PALETTE.map((item) => (
<DraggableComponentItem
key={item.type}
type={item.type}
label={item.label}
icon={item.icon}
description={item.description}
/>
))}
</div>
<p className="mt-2 text-xs text-muted-foreground">
</p>
</div>
</div>
</TabsContent>
{/* 편집 탭 */}
<TabsContent value="edit" className="flex-1 overflow-auto p-2">
{selectedComponent && selectedComponentPosition ? (
<ComponentEditorV3
component={selectedComponent}
position={selectedComponentPosition}
activeModeKey={activeModeKey}
onUpdateDefinition={(updates) =>
onUpdateComponentDefinition(selectedComponent.id, updates)
}
onDelete={() => onDeleteComponent(selectedComponent.id)}
/>
) : (
<div className="flex h-40 items-center justify-center text-sm text-muted-foreground">
</div>
)}
</TabsContent>
</Tabs>
</div>
);
}
// ========================================
// 모드 라벨 헬퍼
// ========================================
function getModeLabel(modeKey: PopLayoutModeKey): string {
const labels: Record<PopLayoutModeKey, string> = {
tablet_landscape: "태블릿 가로",
tablet_portrait: "태블릿 세로",
mobile_landscape: "모바일 가로",
mobile_portrait: "모바일 세로",
};
return labels[modeKey];
}
// ========================================
// 드래그 가능한 컴포넌트 아이템
// ========================================
interface DraggableComponentItemProps {
type: PopComponentType;
label: string;
icon: React.ElementType;
description: string;
}
function DraggableComponentItem({
type,
label,
icon: Icon,
description,
}: DraggableComponentItemProps) {
const [{ isDragging }, drag] = useDrag(() => ({
type: DND_ITEM_TYPES.COMPONENT,
item: { type: DND_ITEM_TYPES.COMPONENT, componentType: type } as DragItemComponent,
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
}));
return (
<div
ref={drag}
className={cn(
"flex cursor-grab items-start gap-3 rounded-lg border p-3 transition-all",
"hover:bg-accent hover:text-accent-foreground",
isDragging && "opacity-50 ring-2 ring-primary"
)}
>
<GripVertical className="mt-0.5 h-4 w-4 text-gray-400" />
<Icon className="mt-0.5 h-4 w-4 shrink-0" />
<div className="flex-1 space-y-1">
<p className="text-sm font-medium leading-none">{label}</p>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
</div>
);
}
// ========================================
// v3 컴포넌트 편집기
// ========================================
interface ComponentEditorV3Props {
component: PopComponentDefinition;
position: { col: number; row: number; colSpan: number; rowSpan: number };
activeModeKey: PopLayoutModeKey;
onUpdateDefinition: (updates: Partial<PopComponentDefinition>) => void;
onDelete: () => void;
}
function ComponentEditorV3({
component,
position,
activeModeKey,
onUpdateDefinition,
onDelete,
}: ComponentEditorV3Props) {
const [isPositionOpen, setIsPositionOpen] = useState(true);
// 컴포넌트 타입 라벨
const typeLabels: Record<PopComponentType, string> = {
"pop-field": "필드",
"pop-button": "버튼",
"pop-list": "리스트",
"pop-indicator": "인디케이터",
"pop-scanner": "스캐너",
"pop-numpad": "숫자패드",
};
return (
<div className="space-y-4">
{/* 컴포넌트 기본 정보 */}
<div className="flex items-center justify-between">
<div>
<span className="text-sm font-medium">{typeLabels[component.type]}</span>
<p className="text-[10px] text-muted-foreground">{component.id}</p>
</div>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:bg-destructive/10"
onClick={onDelete}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
{/* 라벨 */}
<div className="space-y-2">
<Label className="text-xs"></Label>
<Input
value={component.label || ""}
onChange={(e) => onUpdateDefinition({ label: e.target.value })}
placeholder="컴포넌트 이름"
className="h-8 text-xs"
/>
</div>
{/* 현재 모드 위치 (읽기 전용) */}
<Collapsible open={isPositionOpen} onOpenChange={setIsPositionOpen}>
<CollapsibleTrigger className="flex w-full items-center justify-between py-2 text-sm font-medium">
<ChevronDown
className={cn(
"h-4 w-4 transition-transform",
isPositionOpen && "rotate-180"
)}
/>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-3 pt-2">
<div className="rounded-lg bg-muted p-3">
<p className="mb-2 text-xs font-medium">{getModeLabel(activeModeKey)}</p>
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="flex justify-between">
<span className="text-muted-foreground"> :</span>
<span className="font-medium">{position.col}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"> :</span>
<span className="font-medium">{position.row}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"> :</span>
<span className="font-medium">{position.colSpan}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"> :</span>
<span className="font-medium">{position.rowSpan}</span>
</div>
</div>
</div>
<p className="text-[10px] text-muted-foreground">
/ .
(/) .
</p>
</CollapsibleContent>
</Collapsible>
{/* TODO: 컴포넌트별 설정 (config) */}
<div className="rounded-lg border border-dashed p-3">
<p className="text-xs text-muted-foreground text-center">
</p>
</div>
</div>
);
}

View File

@ -1,3 +1,4 @@
// POP 디자이너 패널 export (v5 그리드 시스템)
export { default as ComponentEditorPanel } from "./ComponentEditorPanel";
export { default as ComponentPalette } from "./ComponentPalette";
// POP 디자이너 패널 export
export { PopPanel } from "./PopPanel";
export { ComponentEditorPanel } from "./ComponentEditorPanel";
export { ComponentEditorPanelV4 } from "./ComponentEditorPanelV4";

View File

@ -0,0 +1,237 @@
"use client";
import React, { forwardRef } from "react";
import { cn } from "@/lib/utils";
import {
PopComponentDefinition,
PopComponentType,
GridPosition,
} from "../types/pop-layout";
// ========================================
// Props 정의
// ========================================
interface ComponentRendererProps {
/** 컴포넌트 정의 (타입, 라벨, 설정 등) */
component: PopComponentDefinition;
/** 컴포넌트의 그리드 위치 (섹션 내부 그리드 기준) */
position: GridPosition;
/** 디자인 모드 여부 (true: 편집 가능, false: 뷰어 모드) */
isDesignMode?: boolean;
/** 선택된 상태인지 */
isSelected?: boolean;
/** 컴포넌트 클릭 시 호출 */
onClick?: () => void;
/** 추가 className */
className?: string;
}
// ========================================
// 컴포넌트 렌더러
//
// 역할:
// - 관리자가 설정한 GridPosition(col, row, colSpan, rowSpan)을
// 그대로 CSS Grid에 반영
// - 디자이너/뷰어 모두에서 동일한 렌더링 보장
// - 디자인 모드에서는 선택 상태 표시
// ========================================
export const ComponentRenderer = forwardRef<HTMLDivElement, ComponentRendererProps>(
function ComponentRenderer(
{
component,
position,
isDesignMode = false,
isSelected = false,
onClick,
className,
},
ref
) {
const { type, label, config } = component;
return (
<div
ref={ref}
className={cn(
// 기본 스타일
"relative flex flex-col overflow-hidden rounded border bg-white transition-all",
// 디자인 모드 스타일
isDesignMode && "cursor-pointer",
// 선택 상태 스타일
isSelected
? "border-primary ring-2 ring-primary/30 z-10"
: "border-gray-200 hover:border-gray-300",
className
)}
style={{
// 관리자가 설정한 GridPosition을 그대로 반영
gridColumn: `${position.col} / span ${position.colSpan}`,
gridRow: `${position.row} / span ${position.rowSpan}`,
}}
onClick={(e) => {
e.stopPropagation();
onClick?.();
}}
>
{/* 컴포넌트 타입별 미리보기 렌더링 */}
<ComponentPreview type={type} label={label} config={config} />
</div>
);
}
);
// ========================================
// 컴포넌트 타입별 미리보기
// ========================================
interface ComponentPreviewProps {
type: PopComponentType;
label?: string;
config?: any;
}
function ComponentPreview({ type, label, config }: ComponentPreviewProps) {
switch (type) {
case "pop-field":
return <FieldPreview label={label} config={config} />;
case "pop-button":
return <ButtonPreview label={label} config={config} />;
case "pop-list":
return <ListPreview label={label} config={config} />;
case "pop-indicator":
return <IndicatorPreview label={label} config={config} />;
case "pop-scanner":
return <ScannerPreview label={label} config={config} />;
case "pop-numpad":
return <NumpadPreview label={label} config={config} />;
default:
return (
<div className="flex h-full items-center justify-center p-2 text-xs text-gray-400">
{label || type}
</div>
);
}
}
// ========================================
// 개별 컴포넌트 미리보기
// ========================================
function FieldPreview({ label, config }: { label?: string; config?: any }) {
const fieldType = config?.fieldType || "text";
const placeholder = config?.placeholder || "입력하세요";
const required = config?.required || false;
return (
<div className="flex h-full w-full flex-col gap-1 p-2">
{/* 라벨 */}
<span className="text-xs font-medium text-gray-600">
{label || "필드"}
{required && <span className="ml-1 text-destructive">*</span>}
</span>
{/* 입력 필드 미리보기 */}
<div className="flex h-8 w-full items-center rounded border border-gray-200 bg-gray-50 px-2 text-xs text-gray-400">
{placeholder}
</div>
</div>
);
}
function ButtonPreview({ label, config }: { label?: string; config?: any }) {
const buttonType = config?.buttonType || "action";
const variant = buttonType === "submit" ? "bg-primary text-white" : "bg-gray-100 text-gray-700";
return (
<div className="flex h-full w-full items-center justify-center p-2">
<div
className={cn(
"flex h-10 w-full items-center justify-center rounded font-medium",
variant
)}
>
{label || "버튼"}
</div>
</div>
);
}
function ListPreview({ label, config }: { label?: string; config?: any }) {
const itemCount = config?.itemsPerPage || 5;
return (
<div className="flex h-full w-full flex-col gap-1 p-2">
{/* 라벨 */}
<span className="text-xs font-medium text-gray-600">{label || "리스트"}</span>
{/* 리스트 아이템 미리보기 */}
<div className="flex flex-1 flex-col gap-0.5 overflow-hidden">
{Array.from({ length: Math.min(3, itemCount) }).map((_, i) => (
<div key={i} className="h-4 rounded bg-gray-100" />
))}
{itemCount > 3 && (
<div className="text-center text-[10px] text-gray-400">
+{itemCount - 3} more
</div>
)}
</div>
</div>
);
}
function IndicatorPreview({ label, config }: { label?: string; config?: any }) {
const indicatorType = config?.indicatorType || "kpi";
const unit = config?.unit || "";
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-1 p-2">
{/* 라벨 */}
<span className="text-[10px] text-gray-500">{label || "KPI"}</span>
{/* 값 미리보기 */}
<span className="text-xl font-bold text-primary">
0{unit && <span className="text-sm font-normal text-gray-500">{unit}</span>}
</span>
</div>
);
}
function ScannerPreview({ label, config }: { label?: string; config?: any }) {
const scannerType = config?.scannerType || "camera";
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-1 p-2">
{/* QR 아이콘 */}
<div className="flex h-10 w-10 items-center justify-center rounded border-2 border-dashed border-gray-300">
<span className="text-xs text-gray-400">QR</span>
</div>
{/* 라벨 */}
<span className="text-[10px] text-gray-500">{label || "스캐너"}</span>
</div>
);
}
function NumpadPreview({ label, config }: { label?: string; config?: any }) {
const keys = [1, 2, 3, 4, 5, 6, 7, 8, 9, "C", 0, "OK"];
return (
<div className="flex h-full w-full flex-col gap-1 p-2">
{/* 라벨 */}
{label && (
<span className="text-[10px] text-gray-500">{label}</span>
)}
{/* 넘패드 미리보기 */}
<div className="grid flex-1 grid-cols-3 gap-0.5">
{keys.map((key) => (
<div
key={key}
className="flex items-center justify-center rounded bg-gray-100 text-[8px]"
>
{key}
</div>
))}
</div>
</div>
);
}
export default ComponentRenderer;

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

@ -0,0 +1,401 @@
"use client";
import React from "react";
import { cn } from "@/lib/utils";
import {
PopLayoutDataV3,
PopLayoutModeKey,
PopModeLayoutV3,
GridPosition,
MODE_RESOLUTIONS,
PopComponentDefinition,
} from "../types/pop-layout";
// ========================================
// Props 정의
// ========================================
interface PopLayoutRendererProps {
/** 레이아웃 데이터 (v3.0) */
layout: PopLayoutDataV3;
/** 현재 모드 키 (tablet_landscape, tablet_portrait 등) */
modeKey: PopLayoutModeKey;
/** 디자인 모드 여부 (true: 편집 가능, false: 뷰어 모드) */
isDesignMode?: boolean;
/** 선택된 컴포넌트 ID */
selectedComponentId?: string | null;
/** 컴포넌트 클릭 시 호출 */
onComponentClick?: (componentId: string) => void;
/** 배경 클릭 시 호출 (선택 해제용) */
onBackgroundClick?: () => void;
/** 커스텀 모드 레이아웃 (fallback 등에서 변환된 레이아웃 사용 시) */
customModeLayout?: PopModeLayoutV3;
/** 추가 className */
className?: string;
/** 추가 style */
style?: React.CSSProperties;
}
// ========================================
// 컴포넌트 타입별 라벨
// ========================================
const COMPONENT_TYPE_LABELS: Record<string, string> = {
"pop-field": "필드",
"pop-button": "버튼",
"pop-list": "리스트",
"pop-indicator": "인디케이터",
"pop-scanner": "스캐너",
"pop-numpad": "숫자패드",
};
// ========================================
// POP 레이아웃 렌더러 (v3)
//
// 핵심 역할:
// - 디자이너와 뷰어에서 **동일한** 렌더링 결과 보장
// - 컴포넌트가 캔버스에 직접 배치 (섹션 없음)
// - CSS Grid + 1fr 비율 기반
// ========================================
export function PopLayoutRenderer({
layout,
modeKey,
isDesignMode = false,
selectedComponentId,
onComponentClick,
onBackgroundClick,
customModeLayout,
className,
style,
}: PopLayoutRendererProps) {
const { components, layouts, settings } = layout;
const canvasGrid = settings.canvasGrid;
// 현재 모드의 레이아웃
const modeLayout = customModeLayout || layouts[modeKey];
// 컴포넌트가 없으면 빈 상태 표시
if (!modeLayout || Object.keys(modeLayout.componentPositions).length === 0) {
return (
<div
className={cn(
"flex h-full w-full items-center justify-center bg-gray-50",
className
)}
style={style}
onClick={onBackgroundClick}
>
<div className="text-center text-sm text-gray-400">
<p> </p>
{isDesignMode && <p className="mt-1"> </p>}
</div>
</div>
);
}
// 컴포넌트 ID 목록
const componentIds = Object.keys(modeLayout.componentPositions);
return (
<div
className={cn("relative w-full h-full bg-white", className)}
style={{
// CSS Grid: 디자이너와 동일
display: "grid",
gridTemplateColumns: `repeat(${canvasGrid.columns}, 1fr)`,
gridTemplateRows: `repeat(${canvasGrid.rows || 24}, 1fr)`,
gap: `${canvasGrid.gap}px`,
padding: `${canvasGrid.gap}px`,
...style,
}}
onClick={(e) => {
if (e.target === e.currentTarget) {
onBackgroundClick?.();
}
}}
>
{/* 컴포넌트들 직접 렌더링 */}
{componentIds.map((componentId) => {
const compDef = components[componentId];
const compPos = modeLayout.componentPositions[componentId];
if (!compDef || !compPos) return null;
return (
<ComponentRenderer
key={componentId}
componentId={componentId}
component={compDef}
position={compPos}
isDesignMode={isDesignMode}
isSelected={selectedComponentId === componentId}
onComponentClick={() => onComponentClick?.(componentId)}
/>
);
})}
</div>
);
}
// ========================================
// 컴포넌트 렌더러
// ========================================
interface ComponentRendererProps {
componentId: string;
component: PopComponentDefinition;
position: GridPosition;
isDesignMode?: boolean;
isSelected?: boolean;
onComponentClick?: () => void;
}
function ComponentRenderer({
componentId,
component,
position,
isDesignMode = false,
isSelected = false,
onComponentClick,
}: ComponentRendererProps) {
const typeLabel = COMPONENT_TYPE_LABELS[component.type] || component.type;
return (
<div
className={cn(
"relative flex flex-col overflow-hidden rounded-lg border-2 bg-white transition-all",
isSelected
? "border-primary ring-2 ring-primary/30 z-10"
: "border-gray-200",
isDesignMode && "cursor-pointer hover:border-gray-300"
)}
style={{
gridColumn: `${position.col} / span ${position.colSpan}`,
gridRow: `${position.row} / span ${position.rowSpan}`,
}}
onClick={(e) => {
e.stopPropagation();
onComponentClick?.();
}}
>
{/* 컴포넌트 라벨 (디자인 모드에서만) */}
{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">
{component.label || typeLabel}
</span>
</div>
)}
{/* 컴포넌트 내용 */}
<div className="flex flex-1 items-center justify-center p-2">
{renderComponentContent(component, isDesignMode)}
</div>
</div>
);
}
// ========================================
// 컴포넌트별 렌더링
// ========================================
function renderComponentContent(
component: PopComponentDefinition,
isDesignMode: boolean
): React.ReactNode {
const typeLabel = COMPONENT_TYPE_LABELS[component.type] || component.type;
// 디자인 모드에서는 플레이스홀더 표시
if (isDesignMode) {
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>
);
default:
return (
<div className="text-xs text-gray-400">
{typeLabel}
</div>
);
}
}
// ========================================
// 헬퍼 함수들 (export)
// ========================================
/**
*
*/
export function hasModeLayout(
layout: PopLayoutDataV3,
modeKey: PopLayoutModeKey
): boolean {
const modeLayout = layout.layouts[modeKey];
return modeLayout && Object.keys(modeLayout.componentPositions).length > 0;
}
/**
* 릿 ( )
*/
export function hasBaseLayout(layout: PopLayoutDataV3): boolean {
return hasModeLayout(layout, "tablet_landscape");
}
/**
* 릿
*/
export function autoConvertLayout(
layout: PopLayoutDataV3,
targetModeKey: PopLayoutModeKey
): PopModeLayoutV3 {
const sourceKey: PopLayoutModeKey = "tablet_landscape";
const sourceLayout = layout.layouts[sourceKey];
const sourceRes = MODE_RESOLUTIONS[sourceKey];
const targetRes = MODE_RESOLUTIONS[targetModeKey];
// 비율 계산
const widthRatio = targetRes.width / sourceRes.width;
const heightRatio = targetRes.height / sourceRes.height;
// 가로 → 세로 변환인지 확인
const isOrientationChange =
sourceRes.width > sourceRes.height !== targetRes.width > targetRes.height;
// 컴포넌트 위치 변환
const convertedPositions: Record<string, GridPosition> = {};
let currentRow = 1;
// 컴포넌트를 row, col 순으로 정렬
const sortedComponentIds = Object.keys(sourceLayout.componentPositions).sort(
(a, b) => {
const posA = sourceLayout.componentPositions[a];
const posB = sourceLayout.componentPositions[b];
if (posA.row !== posB.row) return posA.row - posB.row;
return posA.col - posB.col;
}
);
for (const componentId of sortedComponentIds) {
const sourcePos = sourceLayout.componentPositions[componentId];
if (isOrientationChange) {
// 가로 → 세로: 세로 스택 방식
const canvasColumns = layout.settings.canvasGrid.columns;
convertedPositions[componentId] = {
col: 1,
row: currentRow,
colSpan: canvasColumns,
rowSpan: Math.max(3, Math.round(sourcePos.rowSpan * 1.5)),
};
currentRow += convertedPositions[componentId].rowSpan + 1;
} else {
// 같은 방향: 비율 변환
convertedPositions[componentId] = {
col: Math.max(1, Math.round(sourcePos.col * widthRatio)),
row: Math.max(1, Math.round(sourcePos.row * heightRatio)),
colSpan: Math.max(1, Math.round(sourcePos.colSpan * widthRatio)),
rowSpan: Math.max(1, Math.round(sourcePos.rowSpan * heightRatio)),
};
}
}
return {
componentPositions: convertedPositions,
};
}
/**
* ( )
*/
export function getEffectiveModeLayout(
layout: PopLayoutDataV3,
targetModeKey: PopLayoutModeKey
): {
modeLayout: PopModeLayoutV3;
isConverted: boolean;
sourceModeKey: PopLayoutModeKey;
} {
// 해당 모드에 레이아웃이 있으면 그대로 사용
if (hasModeLayout(layout, targetModeKey)) {
return {
modeLayout: layout.layouts[targetModeKey],
isConverted: false,
sourceModeKey: targetModeKey,
};
}
// 없으면 태블릿 가로 모드를 기준으로 자동 변환
return {
modeLayout: autoConvertLayout(layout, targetModeKey),
isConverted: true,
sourceModeKey: "tablet_landscape",
};
}
export default PopLayoutRenderer;

View File

@ -1,280 +0,0 @@
"use client";
import React, { useMemo } from "react";
import { cn } from "@/lib/utils";
import {
PopLayoutDataV5,
PopComponentDefinitionV5,
PopGridPosition,
GridMode,
GRID_BREAKPOINTS,
detectGridMode,
PopComponentType,
} from "../types/pop-layout";
// ========================================
// Props
// ========================================
interface PopRendererProps {
/** v5 레이아웃 데이터 */
layout: PopLayoutDataV5;
/** 현재 뷰포트 너비 */
viewportWidth: number;
/** 현재 모드 (자동 감지 또는 수동 지정) */
currentMode?: GridMode;
/** 디자인 모드 여부 */
isDesignMode?: boolean;
/** 그리드 가이드 표시 여부 */
showGridGuide?: boolean;
/** 선택된 컴포넌트 ID */
selectedComponentId?: string | null;
/** 컴포넌트 클릭 */
onComponentClick?: (componentId: string) => void;
/** 배경 클릭 */
onBackgroundClick?: () => void;
/** 추가 className */
className?: string;
}
// ========================================
// 컴포넌트 타입별 라벨
// ========================================
const COMPONENT_TYPE_LABELS: Record<PopComponentType, string> = {
"pop-sample": "샘플",
};
// ========================================
// PopRenderer: v5 그리드 렌더러
// ========================================
export default function PopRenderer({
layout,
viewportWidth,
currentMode,
isDesignMode = false,
showGridGuide = true,
selectedComponentId,
onComponentClick,
onBackgroundClick,
className,
}: PopRendererProps) {
const { gridConfig, components, overrides } = layout;
// 현재 모드 (자동 감지 또는 지정)
const mode = currentMode || detectGridMode(viewportWidth);
const breakpoint = GRID_BREAKPOINTS[mode];
// CSS Grid 스타일
const gridStyle = useMemo((): React.CSSProperties => ({
display: "grid",
gridTemplateColumns: `repeat(${breakpoint.columns}, 1fr)`,
gridAutoRows: `${breakpoint.rowHeight}px`,
gap: `${breakpoint.gap}px`,
padding: `${breakpoint.padding}px`,
minHeight: "100%",
backgroundColor: "#ffffff",
position: "relative",
}), [breakpoint]);
// 그리드 가이드 셀 생성
const gridCells = useMemo(() => {
if (!isDesignMode || !showGridGuide) return [];
const cells = [];
const rowCount = 20; // 충분한 행 수
for (let row = 1; row <= rowCount; row++) {
for (let col = 1; col <= breakpoint.columns; col++) {
cells.push({
id: `cell-${col}-${row}`,
col,
row
});
}
}
return cells;
}, [isDesignMode, showGridGuide, breakpoint.columns]);
// visibility 체크
const isVisible = (comp: PopComponentDefinitionV5): boolean => {
if (!comp.visibility) return true;
const modeVisibility = comp.visibility[mode];
return modeVisibility !== false;
};
// 위치 변환 (12칸 기준 → 현재 모드 칸 수)
const convertPosition = (position: PopGridPosition): React.CSSProperties => {
const sourceColumns = 12; // 항상 12칸 기준으로 저장
const targetColumns = breakpoint.columns;
// 같은 칸 수면 그대로 사용
if (sourceColumns === targetColumns) {
return {
gridColumn: `${position.col} / span ${position.colSpan}`,
gridRow: `${position.row} / span ${position.rowSpan}`,
};
}
// 비율 계산 (12칸 → 4칸, 6칸, 8칸)
const ratio = targetColumns / sourceColumns;
// 열 위치 변환
let newCol = Math.max(1, Math.ceil((position.col - 1) * ratio) + 1);
let newColSpan = Math.max(1, Math.round(position.colSpan * ratio));
// 범위 초과 방지
if (newCol > targetColumns) {
newCol = 1;
}
if (newCol + newColSpan - 1 > targetColumns) {
newColSpan = targetColumns - newCol + 1;
}
return {
gridColumn: `${newCol} / span ${Math.max(1, newColSpan)}`,
gridRow: `${position.row} / span ${position.rowSpan}`,
};
};
// 오버라이드 적용
const getEffectivePosition = (comp: PopComponentDefinitionV5): PopGridPosition => {
const override = overrides?.[mode]?.positions?.[comp.id];
if (override) {
return { ...comp.position, ...override };
}
return comp.position;
};
// 오버라이드 숨김 체크
const isHiddenByOverride = (comp: PopComponentDefinitionV5): boolean => {
return overrides?.[mode]?.hidden?.includes(comp.id) ?? false;
};
return (
<div
className={cn("relative min-h-full w-full", className)}
style={gridStyle}
onClick={(e) => {
if (e.target === e.currentTarget) {
onBackgroundClick?.();
}
}}
>
{/* 그리드 가이드 셀 (실제 DOM) */}
{gridCells.map(cell => (
<div
key={cell.id}
className="pointer-events-none border border-dashed border-blue-300/40"
style={{
gridColumn: cell.col,
gridRow: cell.row,
}}
/>
))}
{/* 컴포넌트 렌더링 (z-index로 위에 표시) */}
{Object.values(components).map((comp) => {
// visibility 체크
if (!isVisible(comp)) return null;
// 오버라이드 숨김 체크
if (isHiddenByOverride(comp)) return null;
const position = getEffectivePosition(comp);
const positionStyle = convertPosition(position);
const isSelected = selectedComponentId === comp.id;
return (
<div
key={comp.id}
className={cn(
"relative rounded-lg border-2 bg-white transition-all overflow-hidden z-10",
isSelected
? "border-primary ring-2 ring-primary/30"
: "border-gray-200",
isDesignMode && "cursor-pointer hover:border-gray-300 hover:shadow-sm"
)}
style={positionStyle}
onClick={(e) => {
e.stopPropagation();
onComponentClick?.(comp.id);
}}
>
<ComponentContent
component={comp}
isDesignMode={isDesignMode}
isSelected={isSelected}
/>
</div>
);
})}
</div>
);
}
// ========================================
// 컴포넌트 내용 렌더링
// ========================================
interface ComponentContentProps {
component: PopComponentDefinitionV5;
isDesignMode: boolean;
isSelected: boolean;
}
function ComponentContent({ component, isDesignMode, isSelected }: ComponentContentProps) {
const typeLabel = COMPONENT_TYPE_LABELS[component.type] || component.type;
// 디자인 모드: 플레이스홀더 표시
if (isDesignMode) {
return (
<div className="flex h-full w-full flex-col">
{/* 헤더 */}
<div
className={cn(
"flex h-5 shrink-0 items-center border-b px-2",
isSelected ? "bg-primary/10 border-primary" : "bg-gray-50 border-gray-200"
)}
>
<span className={cn(
"text-[10px] font-medium truncate",
isSelected ? "text-primary" : "text-gray-600"
)}>
{component.label || typeLabel}
</span>
</div>
{/* 내용 */}
<div className="flex flex-1 items-center justify-center p-2">
<span className="text-xs text-gray-400">{typeLabel}</span>
</div>
{/* 위치 정보 표시 */}
<div className="absolute bottom-1 right-1 text-[9px] text-gray-400 bg-white/80 px-1 rounded">
{component.position.col},{component.position.row}
({component.position.colSpan}×{component.position.rowSpan})
</div>
</div>
);
}
// 실제 모드: 컴포넌트 렌더링
return renderActualComponent(component);
}
// ========================================
// 실제 컴포넌트 렌더링 (뷰어 모드)
// ========================================
function renderActualComponent(component: PopComponentDefinitionV5): React.ReactNode {
const typeLabel = COMPONENT_TYPE_LABELS[component.type];
// 샘플 박스 렌더링
return (
<div className="flex h-full w-full items-center justify-center p-2">
<span className="text-xs text-gray-500">{component.label || typeLabel}</span>
</div>
);
}

View File

@ -1,4 +1,11 @@
// POP 레이아웃 렌더러 모듈 (v5 그리드 시스템)
// POP 레이아웃 렌더러 모듈 (v3)
// 디자이너와 뷰어에서 동일한 렌더링을 보장하기 위한 공용 렌더러
// 섹션 제거됨, 컴포넌트 직접 배치
export { default as PopRenderer } from "./PopRenderer";
export { PopLayoutRenderer, default } from "./PopLayoutRenderer";
export {
hasModeLayout,
hasBaseLayout,
autoConvertLayout,
getEffectiveModeLayout,
} from "./PopLayoutRenderer";

File diff suppressed because it is too large Load Diff

View File

@ -1,301 +0,0 @@
import {
PopGridPosition,
GridMode,
GRID_BREAKPOINTS
} from "../types/pop-layout";
// ========================================
// 그리드 위치 변환
// ========================================
/**
* 12
*/
export function convertPositionToMode(
position: PopGridPosition,
targetMode: GridMode
): PopGridPosition {
const sourceColumns = 12;
const targetColumns = GRID_BREAKPOINTS[targetMode].columns;
// 같은 칸 수면 그대로 반환
if (sourceColumns === targetColumns) {
return position;
}
const ratio = targetColumns / sourceColumns;
// 열 위치 변환
let newCol = Math.max(1, Math.ceil((position.col - 1) * ratio) + 1);
let newColSpan = Math.max(1, Math.round(position.colSpan * ratio));
// 범위 초과 방지
if (newCol > targetColumns) {
newCol = 1;
}
if (newCol + newColSpan - 1 > targetColumns) {
newColSpan = targetColumns - newCol + 1;
}
return {
col: newCol,
row: position.row,
colSpan: Math.max(1, newColSpan),
rowSpan: position.rowSpan,
};
}
// ========================================
// 겹침 감지 및 해결
// ========================================
/**
*
*/
export function isOverlapping(a: PopGridPosition, b: PopGridPosition): boolean {
// 열 겹침 체크
const aColEnd = a.col + a.colSpan - 1;
const bColEnd = b.col + b.colSpan - 1;
const colOverlap = !(aColEnd < b.col || bColEnd < a.col);
// 행 겹침 체크
const aRowEnd = a.row + a.rowSpan - 1;
const bRowEnd = b.row + b.rowSpan - 1;
const rowOverlap = !(aRowEnd < b.row || bRowEnd < a.row);
return colOverlap && rowOverlap;
}
/**
* ( )
*/
export function resolveOverlaps(
positions: Array<{ id: string; position: PopGridPosition }>,
columns: number
): Array<{ id: string; position: PopGridPosition }> {
// row, col 순으로 정렬
const sorted = [...positions].sort((a, b) =>
a.position.row - b.position.row || a.position.col - b.position.col
);
const resolved: Array<{ id: string; position: PopGridPosition }> = [];
sorted.forEach((item) => {
let { row, col, colSpan, rowSpan } = item.position;
// 열이 범위를 초과하면 조정
if (col + colSpan - 1 > columns) {
colSpan = columns - col + 1;
}
// 기존 배치와 겹치면 아래로 이동
let attempts = 0;
const maxAttempts = 100;
while (attempts < maxAttempts) {
const currentPos: PopGridPosition = { col, row, colSpan, rowSpan };
const hasOverlap = resolved.some(r => isOverlapping(currentPos, r.position));
if (!hasOverlap) break;
row++;
attempts++;
}
resolved.push({
id: item.id,
position: { col, row, colSpan, rowSpan },
});
});
return resolved;
}
// ========================================
// 좌표 변환
// ========================================
/**
*
*/
export function mouseToGridPosition(
mouseX: number,
mouseY: number,
canvasRect: DOMRect,
columns: number,
rowHeight: number,
gap: number,
padding: number
): { col: number; row: number } {
// 캔버스 내 상대 위치
const relX = mouseX - canvasRect.left - padding;
const relY = mouseY - canvasRect.top - padding;
// 칸 너비 계산
const totalGap = gap * (columns - 1);
const colWidth = (canvasRect.width - padding * 2 - totalGap) / columns;
// 그리드 좌표 계산 (1부터 시작)
const col = Math.max(1, Math.min(columns, Math.floor(relX / (colWidth + gap)) + 1));
const row = Math.max(1, Math.floor(relY / (rowHeight + gap)) + 1);
return { col, row };
}
/**
*
*/
export function gridToPixelPosition(
col: number,
row: number,
colSpan: number,
rowSpan: number,
canvasWidth: number,
columns: number,
rowHeight: number,
gap: number,
padding: number
): { x: number; y: number; width: number; height: number } {
const totalGap = gap * (columns - 1);
const colWidth = (canvasWidth - padding * 2 - totalGap) / columns;
return {
x: padding + (col - 1) * (colWidth + gap),
y: padding + (row - 1) * (rowHeight + gap),
width: colWidth * colSpan + gap * (colSpan - 1),
height: rowHeight * rowSpan + gap * (rowSpan - 1),
};
}
// ========================================
// 위치 검증
// ========================================
/**
*
*/
export function isValidPosition(
position: PopGridPosition,
columns: number
): boolean {
return (
position.col >= 1 &&
position.row >= 1 &&
position.colSpan >= 1 &&
position.rowSpan >= 1 &&
position.col + position.colSpan - 1 <= columns
);
}
/**
*
*/
export function clampPosition(
position: PopGridPosition,
columns: number
): PopGridPosition {
let { col, row, colSpan, rowSpan } = position;
// 최소값 보장
col = Math.max(1, col);
row = Math.max(1, row);
colSpan = Math.max(1, colSpan);
rowSpan = Math.max(1, rowSpan);
// 열 범위 초과 방지
if (col + colSpan - 1 > columns) {
if (col > columns) {
col = 1;
}
colSpan = columns - col + 1;
}
return { col, row, colSpan, rowSpan };
}
// ========================================
// 자동 배치
// ========================================
/**
*
*/
export function findNextEmptyPosition(
existingPositions: PopGridPosition[],
colSpan: number,
rowSpan: number,
columns: number
): PopGridPosition {
let row = 1;
let col = 1;
const maxAttempts = 1000;
let attempts = 0;
while (attempts < maxAttempts) {
const candidatePos: PopGridPosition = { col, row, colSpan, rowSpan };
// 범위 체크
if (col + colSpan - 1 > columns) {
col = 1;
row++;
continue;
}
// 겹침 체크
const hasOverlap = existingPositions.some(pos =>
isOverlapping(candidatePos, pos)
);
if (!hasOverlap) {
return candidatePos;
}
// 다음 위치로 이동
col++;
if (col + colSpan - 1 > columns) {
col = 1;
row++;
}
attempts++;
}
// 실패 시 마지막 행에 배치
return { col: 1, row: row + 1, colSpan, rowSpan };
}
/**
*
*/
export function autoLayoutComponents(
components: Array<{ id: string; colSpan: number; rowSpan: number }>,
columns: number
): Array<{ id: string; position: PopGridPosition }> {
const result: Array<{ id: string; position: PopGridPosition }> = [];
let currentRow = 1;
let currentCol = 1;
components.forEach(comp => {
// 현재 행에 공간이 부족하면 다음 행으로
if (currentCol + comp.colSpan - 1 > columns) {
currentRow++;
currentCol = 1;
}
result.push({
id: comp.id,
position: {
col: currentCol,
row: currentRow,
colSpan: comp.colSpan,
rowSpan: comp.rowSpan,
},
});
currentCol += comp.colSpan;
});
return result;
}

View File

@ -1,273 +1,588 @@
# POP 화면 시스템 아키텍처
**최종 업데이트: 2026-02-05 (v5 그리드 시스템)**
**최종 업데이트: 2026-02-04**
POP(Point of Production) 화면은 모바일/태블릿 환경에 최적화된 터치 기반 화면 시스템입니다.
이 문서는 POP 화면 구현에 관련된 모든 파일과 그 역할을 정리합니다.
---
## 현재 버전: v5 (CSS Grid)
## 목차
| 항목 | v5 (현재) |
|------|----------|
| 레이아웃 | CSS Grid |
| 배치 방식 | 좌표 기반 (col, row, colSpan, rowSpan) |
| 모드 | 4개 (mobile_portrait, mobile_landscape, tablet_portrait, tablet_landscape) |
| 칸 수 | 4/6/8/12칸 |
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
├── app/(pop)/ # Next.js App Router - POP 라우팅
│ ├── layout.tsx # POP 전용 레이아웃
│ └── pop/
│ ├── page.tsx # 대시보드
│ ├── screens/[screenId]/ # 화면 뷰어 (v5)
│ ├── page.tsx # POP 대시보드 (메인)
│ ├── screens/[screenId]/ # 개별 POP 화면 뷰어
│ ├── test-v4/ # v4 렌더러 테스트 페이지
│ └── work/ # 작업 화면
├── components/pop/ # POP 컴포넌트
│ ├── designer/ # 디자이너 모듈 ★
│ │ ├── PopDesigner.tsx # 메인 (레이아웃 로드/저장)
│ │ ├── PopCanvas.tsx # 캔버스 (DnD, 줌, 모드)
│ │ ├── panels/
│ │ │ └── ComponentEditorPanel.tsx # 속성 편집
│ │ ├── renderers/
│ │ │ └── PopRenderer.tsx # CSS Grid 렌더링
│ │ ├── types/
│ │ │ └── pop-layout.ts # v5 타입 정의
│ │ └── utils/
│ │ └── gridUtils.ts # 위치 계산
│ ├── management/ # 화면 관리
│ └── dashboard/ # 대시보드
├── components/pop/ # POP 컴포넌트 라이브러리
│ ├── designer/ # 디자이너 모듈
│ │ ├── panels/ # 편집 패널 (좌측/우측)
│ │ ├── renderers/ # 레이아웃 렌더러
│ │ └── types/ # 타입 정의
│ ├── management/ # 화면 관리 모듈
│ └── dashboard/ # 대시보드 모듈
└── lib/
├── api/screen.ts # 화면 API
└── registry/ # 컴포넌트 레지스트리
├── api/popScreenGroup.ts # POP 화면 그룹 API
├── registry/PopComponentRegistry.ts # 컴포넌트 레지스트리
└── schemas/popComponentConfig.ts # 컴포넌트 설정 스키마
```
---
## 핵심 파일
## 2. App 라우팅 (app/(pop))
### 1. PopDesigner.tsx (메인)
### `app/(pop)/layout.tsx`
**역할**: 레이아웃 로드/저장, 컴포넌트 CRUD, 히스토리
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
// 상태 관리
const [layout, setLayout] = useState<PopLayoutDataV5>(createEmptyPopLayoutV5());
const [selectedComponentId, setSelectedComponentId] = useState<string | null>(null);
const [currentMode, setCurrentMode] = useState<GridMode>("tablet_landscape");
const [history, setHistory] = useState<PopLayoutDataV5[]>([]);
// 뷰포트 너비 감지 (최대 1366px 제한)
const [viewportWidth, setViewportWidth] = useState(1024);
// 핵심 함수
handleSave() // 레이아웃 저장
handleAddComponent() // 컴포넌트 추가
handleUpdateComponent() // 컴포넌트 수정
handleDeleteComponent() // 컴포넌트 삭제
handleUndo() / handleRedo() // 히스토리
```
### 2. PopCanvas.tsx (캔버스)
**역할**: 그리드 캔버스, DnD, 줌, 패닝, 모드 전환
```typescript
// DnD 설정
const DND_ITEM_TYPES = { COMPONENT: "component" };
// 뷰포트 프리셋 (4개 모드)
const VIEWPORT_PRESETS = [
{ id: "mobile_portrait", width: 375, columns: 4 },
{ id: "mobile_landscape", width: 667, columns: 6 },
{ id: "tablet_portrait", width: 768, columns: 8 },
{ id: "tablet_landscape", width: 1024, columns: 12 },
];
// 기능
- useDrop(): 팔레트에서 컴포넌트 드롭
- handleWheel(): 줌 (30%~150%)
- Space + 드래그: 패닝
```
### 3. PopRenderer.tsx (렌더러)
**역할**: CSS Grid 기반 레이아웃 렌더링
```typescript
// Props
interface PopRendererProps {
layout: PopLayoutDataV5;
viewportWidth: number;
currentMode: GridMode;
isDesignMode: boolean;
selectedComponentId?: string | null;
onSelectComponent?: (id: string | null) => void;
}
// CSS Grid 스타일 생성
const gridStyle = useMemo(() => ({
display: "grid",
gridTemplateColumns: `repeat(${columns}, 1fr)`,
gridTemplateRows: `repeat(${rows}, 1fr)`,
gap: `${gap}px`,
padding: `${padding}px`,
}), [mode]);
// 위치 변환 (12칸 → 다른 모드)
const convertPosition = (pos: PopGridPosition, targetMode: GridMode) => {
const ratio = GRID_BREAKPOINTS[targetMode].columns / 12;
return {
col: Math.max(1, Math.round(pos.col * ratio)),
colSpan: Math.max(1, Math.round(pos.colSpan * ratio)),
row: pos.row,
rowSpan: pos.rowSpan,
useEffect(() => {
const updateViewportWidth = () => {
setViewportWidth(Math.min(window.innerWidth, 1366));
};
};
updateViewportWidth();
window.addEventListener("resize", updateViewportWidth);
return () => window.removeEventListener("resize", updateViewportWidth);
}, []);
// 레이아웃 버전 감지 및 렌더링
if (popLayoutV4) {
// v4: PopFlexRenderer 사용 (비율 스케일링 적용)
<div className="mx-auto h-full" style={{ maxWidth: 1366 }}>
<PopFlexRenderer
layout={popLayoutV4}
viewportWidth={viewportWidth}
isDesignMode={false} // 뷰어에서 스케일 적용
/>
</div>
} else if (popLayoutV3) {
// v3: PopLayoutRenderer 사용
<PopLayoutV3Renderer layout={popLayoutV3} modeKey={currentModeKey} />
}
```
### 4. ComponentEditorPanel.tsx (속성 패널)
### `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
// 탭 구조
- grid: col, row, colSpan, rowSpan (기본 모드에서만 편집)
- settings: label, type 등
- data: 데이터 바인딩 (미구현)
- visibility: 모드별 표시/숨김
const [layoutMode, setLayoutMode] = useState<"v3" | "v4">("v3");
const [layoutV3, setLayoutV3] = useState<PopLayoutDataV3>(...);
const [layoutV4, setLayoutV4] = useState<PopLayoutDataV4>(...);
const [selectedComponentId, setSelectedComponentId] = useState<string | null>(null);
```
### 5. pop-layout.ts (타입 정의)
**레이아웃**:
```
┌─────────────────────────────────────────────────┐
│ 툴바 (뒤로가기, 화면명, 모드전환, 저장) │
├──────────┬──────────────────────────┬──────────┤
│ 왼쪽 │ 중앙 캔버스 │ 오른쪽 │
│ 패널 │ │ 패널 │
│ (20%) │ (60%) │ (20%) │
│ │ │ (v4만) │
└──────────┴──────────────────────────┴──────────┘
```
**역할**: v5 타입 정의
#### `PopCanvas.tsx` (v3용)
**역할**: v3 레이아웃용 CSS Grid 기반 캔버스
**핵심 기능**:
- 4개 모드 전환 (태블릿 가로/세로, 모바일 가로/세로)
- 그리드 기반 컴포넌트 배치
- 드래그로 위치/크기 조정
#### `PopCanvasV4.tsx` (v4용)
**역할**: v4 레이아웃용 Flexbox 기반 캔버스
**핵심 기능**:
- 단일 캔버스 + 뷰포트 프리뷰
- 3가지 프리셋 (모바일 375px, 태블릿 768px, 데스크톱 1024px)
- 너비 슬라이더로 반응형 테스트
- 줌 컨트롤 (30%~150%)
```typescript
// 그리드 모드
type GridMode = "mobile_portrait" | "mobile_landscape" | "tablet_portrait" | "tablet_landscape";
const VIEWPORT_PRESETS = [
{ id: "mobile", label: "모바일", width: 375, height: 667 },
{ id: "tablet", label: "태블릿", width: 768, height: 1024 },
{ id: "desktop", label: "데스크톱", width: 1024, height: 768 },
];
```
// 브레이크포인트 설정
const GRID_BREAKPOINTS = {
mobile_portrait: { columns: 4, rowHeight: 48, gap: 8, padding: 12 },
mobile_landscape: { columns: 6, rowHeight: 44, gap: 8, padding: 16 },
tablet_portrait: { columns: 8, rowHeight: 52, gap: 12, padding: 20 },
tablet_landscape: { columns: 12, rowHeight: 56, gap: 12, padding: 24 },
};
---
// 레이아웃 데이터
interface PopLayoutDataV5 {
version: "pop-5.0";
metadata: PopLayoutMetadata;
gridConfig: PopGridConfig;
components: PopComponentDefinitionV5[];
globalSettings: PopGlobalSettingsV5;
### 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
// 기준 너비 (10인치 태블릿 가로)
const BASE_VIEWPORT_WIDTH = 1024;
// 스케일 계산 (디자인 모드: 1, 뷰어 모드: 실제 비율)
const scale = isDesignMode ? 1 : viewportWidth / BASE_VIEWPORT_WIDTH;
// 예시: 12인치(1366px) 화면
// scale = 1366 / 1024 = 1.33
// 200px 컴포넌트 → 266px
```
**크기 제약 변환 로직** (스케일 적용):
```typescript
function calculateSizeStyle(
size: PopSizeConstraintV4,
settings: PopGlobalSettingsV4,
scale: number = 1 // 스케일 파라미터 추가
): React.CSSProperties {
const style: React.CSSProperties = {};
// 너비 (스케일 적용)
switch (size.width) {
case "fixed":
style.width = `${size.fixedWidth * scale}px`;
style.flexShrink = 0;
break;
case "fill":
style.flex = 1;
style.minWidth = size.minWidth ? `${size.minWidth * scale}px` : 0;
break;
case "hug":
style.width = "auto";
style.flexShrink = 0;
break;
}
// 높이 (스케일 적용)
switch (size.height) {
case "fixed":
style.height = `${size.fixedHeight * scale}px`;
break;
case "fill":
style.flexGrow = 1;
break;
case "hug":
style.height = "auto";
break;
}
return style;
}
```
**컨테이너 스케일 적용**:
```typescript
// gap, padding도 스케일 적용
const scaledGap = gap * scale;
const scaledPadding = padding ? padding * scale : undefined;
```
#### `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 PopComponentDefinitionV5 {
interface PopContainerV4 {
id: string;
type: PopComponentType;
label: string;
gridPosition: PopGridPosition; // col, row, colSpan, rowSpan
config: PopComponentConfig;
visibility: Record<GridMode, boolean>;
modeOverrides?: Record<GridMode, PopModeOverrideV5>;
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 PopGridPosition {
col: number; // 시작 열 (1부터)
row: number; // 시작 행 (1부터)
colSpan: number; // 열 크기 (1~12)
rowSpan: number; // 행 크기 (1~)
interface PopSizeConstraintV4 {
width: "fixed" | "fill" | "hug";
height: "fixed" | "fill" | "hug";
fixedWidth?: number;
fixedHeight?: number;
minWidth?: number;
maxWidth?: number;
minHeight?: number;
}
```
### 6. gridUtils.ts (유틸리티)
### 버전 비교표
**역할**: 그리드 위치 계산
| 항목 | v3 | v4 |
|------|----|----|
| 설계 횟수 | 4번 (모드별) | 1번 |
| 위치 지정 | col, row, colSpan, rowSpan | 제약 (fill/fixed/hug) |
| 렌더링 | CSS Grid | Flexbox |
| 반응형 | 수동 (모드 전환) | 자동 (breakpoint 규칙) |
| 복잡도 | 높음 | 낮음 |
```typescript
// 위치 변환
convertPositionToMode(pos, targetMode)
---
// 겹침 감지
isOverlapping(posA, posB)
## 6. 데이터 흐름
// 빈 위치 찾기
findNextEmptyPosition(layout, mode)
### 화면 로드 흐름
// 마우스 → 그리드 좌표
mouseToGridPosition(mouseX, mouseY, canvasRect, mode)
```
[사용자 접속]
[/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]
```
---
## 데이터 흐름
## 관련 문서
```
[사용자 액션]
[PopDesigner] ← 상태 관리 (layout, selectedComponentId, history)
[PopCanvas] ← DnD, 줌, 모드 전환
[PopRenderer] ← CSS Grid 렌더링
[컴포넌트 표시]
```
### 저장 흐름
```
[저장 버튼]
PopDesigner.handleSave()
screenApi.saveLayoutPop(screenId, layout)
[백엔드] screenManagementService.saveLayoutPop()
[DB] screen_layouts_pop 테이블
```
### 로드 흐름
```
[페이지 로드]
PopDesigner useEffect
screenApi.getLayoutPop(screenId)
isV5Layout(data) 체크
setLayout(data) 또는 createEmptyPopLayoutV5()
```
- [PLAN.md](./PLAN.md) - 개발 계획 및 로드맵
- [components-spec.md](./components-spec.md) - 컴포넌트 상세 스펙
- [CHANGELOG.md](./CHANGELOG.md) - 변경 이력
---
## API 엔드포인트
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | `/api/screen-management/layout-pop/:screenId` | 레이아웃 조회 |
| POST | `/api/screen-management/layout-pop/:screenId` | 레이아웃 저장 |
---
## 삭제된 레거시 (참고용)
| 파일 | 버전 | 이유 |
|------|------|------|
| PopCanvasV4.tsx | v4 | Flexbox 기반, v5로 대체 |
| PopFlexRenderer.tsx | v4 | Flexbox 렌더러, v5로 대체 |
| PopLayoutRenderer.tsx | v3 | 절대 좌표 기반, v5로 대체 |
| ComponentEditorPanelV4.tsx | v4 | v5 전용으로 통합 |
---
*상세 스펙: [SPEC.md](./SPEC.md) | 파일 목록: [FILES.md](./FILES.md)*
*이 문서는 POP 화면 시스템의 구조를 이해하고 유지보수하기 위한 참조용으로 작성되었습니다.*

View File

@ -6,202 +6,10 @@
## [미출시]
- Phase 4: 실제 컴포넌트 구현 (pop-field, pop-button 등)
- 데이터 바인딩 구현
- 워크플로우 연동
---
## [2026-02-05] v5 그리드 시스템 완전 통합
### 배경 (왜 v5로 전환했는가)
**문제 상황**:
- v4 Flexbox로 반응형 구현 시도 → 배치가 예측 불가능
- 캔버스에 "그리듯이" 배치하면 화면 크기별로 깨짐
**상급자 피드백**:
> "이런 식이면 나중에 문제가 생긴다."
> "스크린의 픽셀 규격과 마진 간격 규칙을 설정해라.
> 큰 화면 디자인의 전체 프레임 규격과 사이즈 간격 규칙을 정한 다음에
> 거기에 컴포넌트를 끼워 맞추듯 우리의 규칙 내로 움직이게 바탕을 잡아라."
**연구 내용**:
- Softr: 블록 기반, 제약 기반 레이아웃
- Ant Design: 24열 그리드, 8px 간격
- Material Design: 4/8/12열, 반응형 브레이크포인트
**결정**: CSS Grid 기반 그리드 시스템 (v5) 채택
→ 상세: [decisions/003-v5-grid-system.md](./decisions/003-v5-grid-system.md)
### popdocs 문서 구조 재정비
**배경**: 문서가 AI 에이전트 진입점 역할을 못함, 컨텍스트 효율화 필요
**적용 기법**: Progressive Disclosure (점진적 공개), Token as Currency
**추가된 파일**:
- `SAVE_RULES.md`: AI 저장/조회 규칙, 템플릿
- `STATUS.md`: 현재 진행 상태, 중단점
- `PROBLEMS.md`: 문제-해결 색인
- `INDEX.md`: 기능별 색인
- `sessions/`: 날짜별 작업 기록
**문서 계층**:
- Layer 1 (진입점): README, STATUS, SAVE_RULES
- Layer 2 (상세): CHANGELOG, PROBLEMS, INDEX, FILES, ARCHITECTURE
- Layer 3 (심화): decisions/, sessions/, archive/
### Breaking Changes
- **v1, v2, v3, v4 레이아웃 완전 삭제**
- 기존 POP 화면 데이터 전체 초기화 필요
- 레거시 컴포넌트 및 타입 삭제
### Added
- **CSS Grid 기반 그리드 시스템 (v5)**
- 4개 모드별 칸 수: 4/6/8/12칸
- 명시적 위치 지정 (col, row, colSpan, rowSpan)
- 모드별 오버라이드 지원
- 자동 위치 변환 (12칸 기준 → 다른 모드)
- **통합된 파일 구조**
- `PopCanvas.tsx`: 그리드 캔버스 (DnD + 줌 + 모드 전환)
- `PopRenderer.tsx`: 그리드 렌더링
- `ComponentEditorPanel.tsx`: 속성 편집
- `pop-layout.ts`: v5 전용 타입 정의
- `gridUtils.ts`: 그리드 유틸리티 함수
### Removed
- `PopCanvasV4.tsx`, `PopCanvas.tsx (v3)`
- `PopFlexRenderer.tsx`, `PopLayoutRenderer.tsx`
- `ComponentEditorPanelV4.tsx`, `PopPanel.tsx`
- v1, v2, v3, v4 타입 정의 및 유틸리티 함수
- `test-v4` 테스트 페이지
### Changed
- `screenManagementService.ts`: v5 전용으로 단순화
- `screen_layouts_pop` 테이블: 기존 데이터 삭제, v5 전용
- `PopDesigner.tsx`: v5 전용으로 리팩토링
- 뷰어 페이지: v5 렌더러 전용
### Technical Details
```typescript
// v5 그리드 모드
type GridMode = "mobile_portrait" | "mobile_landscape" | "tablet_portrait" | "tablet_landscape";
// 그리드 설정
const GRID_BREAKPOINTS = {
mobile_portrait: { columns: 4, rowHeight: 48, gap: 8, padding: 12 },
mobile_landscape: { columns: 6, rowHeight: 44, gap: 8, padding: 16 },
tablet_portrait: { columns: 8, rowHeight: 52, gap: 12, padding: 20 },
tablet_landscape: { columns: 12, rowHeight: 56, gap: 12, padding: 24 },
};
// 컴포넌트 위치
interface PopGridPosition {
col: number; // 시작 열 (1부터)
row: number; // 시작 행 (1부터)
colSpan: number; // 열 크기
rowSpan: number; // 행 크기
}
```
---
## [2026-02-04] Phase 2.1 완료 - 배치 고정 기능
### Added
- **현재 모드 추적** (PopDesigner.tsx)
- `currentViewportMode` 상태 추가
- PopCanvasV4와 양방향 동기화
- 모드 변경 시 자동 업데이트
- **배치 고정 기능**
- "고정" 버튼 추가 (기본 모드 제외)
- `handleLockLayoutV4()` - 현재 배치를 오버라이드에 저장
- 배치 정보: direction, wrap, gap, alignItems, justifyContent, children 순서
- **오버라이드 초기화 기능**
- `handleResetOverrideV4()` - 오버라이드 삭제
- "자동으로 되돌리기" 버튼 (편집된 모드만 표시)
- 자동 계산으로 되돌림
### Changed
- **PopCanvasV4 Props 구조 변경**
- `currentMode` prop 추가 (외부에서 제어)
- `onModeChange` 콜백 추가
- `onLockLayout` 콜백 추가
- 내부 `activeViewport` 상태 제거 (부모가 관리)
- **프리셋 버튼 동작**
- 클릭 시 부모 상태 업데이트 (`onModeChange`)
- `currentMode` prop 기반으로 활성 상태 표시
### Technical Details
```typescript
// 고정 로직
const handleLockLayoutV4 = () => {
const newLayout = {
...layoutV4,
overrides: {
...layoutV4.overrides,
[currentViewportMode]: {
containers: {
root: {
direction: layoutV4.root.direction,
wrap: layoutV4.root.wrap,
gap: layoutV4.root.gap,
children: layoutV4.root.children, // 순서 고정
// ... 기타 배치 속성
}
}
}
}
};
};
// 초기화 로직
const handleResetOverrideV4 = (mode) => {
const newOverrides = { ...layoutV4.overrides };
delete newOverrides[mode];
// overrides가 비면 undefined로 설정
};
```
### UI 변경
```
툴바:
[모바일↕] [모바일↔] [태블릿↕] [태블릿↔(기본)] [고정] [자동으로 되돌리기]
조건부 표시:
- "고정" 버튼: 기본 모드가 아닐 때
- "자동으로 되돌리기": 오버라이드가 있을 때
```
### 주의사항
- 크기는 고정하지 않음 (여전히 자동 스케일링)
- 배치만 오버라이드 (순서, 방향, 정렬)
- 최소/최대값 기능은 별도 구현 필요
---
## [2026-02-04] Phase 2 시작 - 오버라이드 UI 표시
### Added
- **오버라이드 데이터 구조** (pop-layout.ts)
- `PopModeOverride` 인터페이스 추가
- `PopLayoutDataV4.overrides` 필드 추가
- 3개 모드 오버라이드 지원 (mobile_portrait, mobile_landscape, tablet_portrait)
- **프리셋 버튼 상태 표시** (PopCanvasV4.tsx)
- 기본 모드: "(기본)" 텍스트 표시
- 편집된 모드: "(편집)" 텍스트 + 노란색 강조
- 자동 모드: 기본 스타일
### Changed
- **hasOverride 함수 구현**
- `layout.overrides` 필드 체크
- 컴포넌트/컨테이너 오버라이드 존재 여부 확인
- Phase 2: 모드별 오버라이드 기능
- Phase 3: 컴포넌트 표시/숨김
- Phase 4: 순서 오버라이드
- Tier 2, 3 컴포넌트
---
@ -394,105 +202,6 @@ v4를 기본 레이아웃 모드로 통합하고, 새 화면은 자동으로 v4
---
## [2026-02-04] Phase 2.1 완료 - 배치 고정 기능 (버그 수정)
### 🔥 주요 버그 수정
- **layoutV4.root 오염 문제 해결**: 다른 모드에서 편집 시 기본 레이아웃이 변경되던 버그 수정
- **tempLayout 도입**: 고정 전 임시 배치를 별도 상태로 관리하여 root를 보호
- **렌더러 병합 로직**: `PopFlexRenderer`에 오버라이드 자동 병합 기능 추가
### 데이터 흐름 개선
1. **기본 모드 (태블릿 가로)**
- 드래그/속성 변경 → `layoutV4.root` 직접 수정 ✅
- 모든 다른 모드의 기본값으로 사용
2. **다른 모드 (모바일 세로 등)**
- 드래그 → `tempLayout` 임시 저장 (화면에만 표시)
- "고정" 버튼 → `layoutV4.overrides[mode]`에 저장
- 속성 패널 → 비활성화 + 안내 메시지
3. **렌더링**
- `tempLayout` 있으면 최우선 표시 (고정 전 미리보기)
- 오버라이드 있으면 `root`와 병합
- 없으면 `root` 그대로 표시
### 수정 파일
- `PopDesigner.tsx`: tempLayout 상태 추가, 핸들러 수정
- `PopFlexRenderer.tsx`: 병합 로직 추가 (getMergedRoot)
- `PopCanvasV4.tsx`: tempLayout props 전달
- `ComponentEditorPanelV4.tsx`: 속성 패널 비활성화 로직
---
## [2026-02-04] Phase 3 완료 - visibility + 줄바꿈 컴포넌트
### 추가 기능
- **visibility 속성**: 모드별 컴포넌트 표시/숨김 제어
- **pop-break 컴포넌트**: 강제 줄바꿈 (flex-basis: 100%)
- **컴포넌트 오버라이드 병합**: 모드별 컴포넌트 설정 변경 가능
### 타입 정의
```typescript
interface PopComponentDefinitionV4 {
// 기존 속성...
// 🆕 모드별 표시/숨김
visibility?: {
tablet_landscape?: boolean;
tablet_portrait?: boolean;
mobile_landscape?: boolean;
mobile_portrait?: boolean;
};
}
// 🆕 줄바꿈 컴포넌트
type PopComponentType =
| "pop-field"
| "pop-button"
| "pop-list"
| "pop-indicator"
| "pop-scanner"
| "pop-numpad"
| "pop-spacer"
| "pop-break"; // 새로 추가
```
### 렌더러 개선
- `isComponentVisible()`: visibility 체크 로직
- `getMergedComponent()`: 컴포넌트 오버라이드 병합
- pop-break 전용 렌더링 (디자인 모드: 점선, 실제: 높이 0)
### 삭제 함수 개선
- `cleanupOverridesAfterDelete()`: 컴포넌트 삭제 시 모든 오버라이드 정리
- containers.root.children 정리
- components 오버라이드 정리
- 빈 오버라이드 자동 제거
### UI 개선
- 속성 패널에 "표시" 탭 추가 (Eye 아이콘)
- 모드별 체크박스 UI
- 반응형 숨김 (hideBelow) 유지
- 팔레트에 "줄바꿈" 컴포넌트 추가
### 사용 예시
```
태블릿 가로:
[A] [B] [C] [D] [E] ← 한 줄
모바일 세로:
[A] [B]
─────── ← 줄바꿈 (visibility: mobile만 true)
[C] [D] [E]
```
### 수정 파일
- `pop-layout.ts`: 타입 추가, 삭제 함수 수정
- `PopFlexRenderer.tsx`: visibility, 병합, pop-break 렌더링
- `ComponentEditorPanelV4.tsx`: 표시 탭 추가
- `ComponentPaletteV4.tsx`: 줄바꿈 추가
---
## [2026-02-04] v4 타입 및 렌더러
### Added

View File

@ -1,6 +1,6 @@
# POP 파일 상세 목록
**최종 업데이트: 2026-02-05 (v5 그리드 시스템 통합)**
**최종 업데이트: 2026-02-04**
이 문서는 POP 화면 시스템과 관련된 모든 파일을 나열하고 각 파일의 역할을 설명합니다.
@ -13,11 +13,10 @@
3. [Panels 파일](#3-panels-파일)
4. [Renderers 파일](#4-renderers-파일)
5. [Types 파일](#5-types-파일)
6. [Utils 파일](#6-utils-파일)
7. [Management 파일](#7-management-파일)
8. [Dashboard 파일](#8-dashboard-파일)
9. [Library 파일](#9-library-파일)
10. [루트 컴포넌트 파일](#10-루트-컴포넌트-파일)
6. [Management 파일](#6-management-파일)
7. [Dashboard 파일](#7-dashboard-파일)
8. [Library 파일](#8-library-파일)
9. [루트 컴포넌트 파일](#9-루트-컴포넌트-파일)
---
@ -47,36 +46,34 @@
| 항목 | 내용 |
|------|------|
| 역할 | 개별 POP 화면 뷰어 (v5 전용) |
| 역할 | 개별 POP 화면 뷰어 |
| 경로 | `/pop/screens/:screenId` |
| 버전 | v5 그리드 시스템 전용 |
| 라인 수 | 468줄 |
**핵심 코드 구조**:
```typescript
// v5 레이아웃 상태
const [layout, setLayout] = useState<PopLayoutDataV5>(createEmptyPopLayoutV5());
// 상태
const [popLayoutV3, setPopLayoutV3] = useState<PopLayoutDataV3 | null>(null);
const [popLayoutV4, setPopLayoutV4] = useState<PopLayoutDataV4 | null>(null);
// 레이아웃 로드
useEffect(() => {
const popLayout = await screenApi.getLayoutPop(screenId);
if (isV5Layout(popLayout)) {
setLayout(popLayout);
} else {
// 레거시 레이아웃은 빈 v5로 처리
setLayout(createEmptyPopLayoutV5());
if (isPopLayoutV4(popLayout)) {
setPopLayoutV4(popLayout);
} else if (isPopLayout(popLayout)) {
const v3Layout = ensureV3Layout(popLayout);
setPopLayoutV3(v3Layout);
}
}, [screenId]);
// v5 그리드 렌더링
{hasComponents ? (
<PopRenderer
layout={layout}
viewportWidth={viewportWidth}
currentMode={currentModeKey}
isDesignMode={false}
/>
// 렌더링 분기
{popLayoutV4 ? (
<PopFlexRenderer layout={popLayoutV4} viewportWidth={...} />
) : popLayoutV3 ? (
<PopLayoutV3Renderer layout={popLayoutV3} modeKey={currentModeKey} />
) : (
// 빈 화면
)}
@ -86,7 +83,43 @@ useEffect(() => {
- 반응형 모드 감지 (useResponsiveModeWithOverride)
- 프리뷰 모드 (`?preview=true`)
- 디바이스/방향 수동 전환 (프리뷰 모드)
- 4개 그리드 모드 지원
- 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>
);
}
```
---
@ -105,7 +138,8 @@ useEffect(() => {
| 항목 | 내용 |
|------|------|
| 역할 | POP 화면 디자이너 메인 (v5 전용) |
| 역할 | POP 화면 디자이너 메인 |
| 라인 수 | 524줄 |
| 의존성 | react-dnd, ResizablePanelGroup |
**핵심 Props**:
@ -121,29 +155,41 @@ interface PopDesignerProps {
**상태 관리**:
```typescript
// v5 레이아웃
const [layout, setLayout] = useState<PopLayoutDataV5>(createEmptyPopLayoutV5());
// 레이아웃 모드
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);
// 그리드 모드 (4개)
const [currentMode, setCurrentMode] = useState<GridMode>("tablet_landscape");
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");
```
**주요 핸들러**:
| 핸들러 | 역할 |
|--------|------|
| `handleDropComponent` | 컴포넌트 드롭 (그리드 위치 계산) |
| `handleUpdateComponent` | 컴포넌트 속성 수정 |
| `handleDeleteComponent` | 컴포넌트 삭제 |
| `handleSave` | v5 레이아웃 저장 |
| `handleDropComponentV3` | v3 컴포넌트 드롭 |
| `handleDropComponentV4` | v4 컴포넌트 드롭 |
| `handleUpdateComponentDefinitionV3` | v3 컴포넌트 정의 수정 |
| `handleUpdateComponentV4` | v4 컴포넌트 수정 |
| `handleUpdateContainerV4` | v4 컨테이너 수정 |
| `handleDeleteComponentV3` | v3 컴포넌트 삭제 |
| `handleDeleteComponentV4` | v4 컴포넌트 삭제 |
| `handleSave` | 레이아웃 저장 |
---
@ -151,21 +197,32 @@ const [hasChanges, setHasChanges] = useState(false);
| 항목 | 내용 |
|------|------|
| 역할 | v5 CSS Grid 기반 캔버스 |
| 렌더링 | CSS Grid (4/6/8/12칸) |
| 역할 | v3 CSS Grid 기반 캔버스 |
| 렌더링 | CSS Grid |
| 모드 | 4개 (태블릿/모바일 x 가로/세로) |
---
### `frontend/components/pop/designer/PopCanvasV4.tsx`
| 항목 | 내용 |
|------|------|
| 역할 | v4 Flexbox 기반 캔버스 |
| 라인 수 | 309줄 |
| 렌더링 | Flexbox (via PopFlexRenderer) |
**핵심 Props**:
```typescript
interface PopCanvasProps {
layout: PopLayoutDataV5;
interface PopCanvasV4Props {
layout: PopLayoutDataV4;
selectedComponentId: string | null;
currentMode: GridMode;
onModeChange: (mode: GridMode) => void;
selectedContainerId: string | null;
onSelectComponent: (id: string | null) => void;
onDropComponent: (type: PopComponentType, position: PopGridPosition) => void;
onUpdateComponent: (componentId: string, updates: Partial<PopComponentDefinitionV5>) => 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;
}
```
@ -174,19 +231,18 @@ interface PopCanvasProps {
```typescript
const VIEWPORT_PRESETS = [
{ id: "mobile_portrait", label: "모바일 세로", width: 375, height: 667 }, // 4칸
{ id: "mobile_landscape", label: "모바일 가로", width: 667, height: 375 }, // 6칸
{ id: "tablet_portrait", label: "태블릿 세로", width: 768, height: 1024 }, // 8칸
{ id: "tablet_landscape", label: "태블릿 가로", width: 1024, height: 768 }, // 12칸
{ 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 },
];
```
**제공 기능**:
- 4개 모드 프리셋 전환
- 뷰포트 프리셋 전환
- 너비 슬라이더 (320px ~ 1440px)
- 줌 컨트롤 (30% ~ 150%)
- 패닝 (Space + 드래그)
- 컴포넌트 드래그 앤 드롭
- 그리드 좌표 계산
- 패닝 (Space + 드래그 또는 휠 클릭)
- 컴포넌트 드롭
---
@ -194,31 +250,75 @@ const VIEWPORT_PRESETS = [
```typescript
export { default as PopDesigner } from "./PopDesigner";
export { default as PopCanvas } from "./PopCanvas";
export { default as ComponentEditorPanel } from "./panels/ComponentEditorPanel";
export { default as PopRenderer } from "./renderers/PopRenderer";
export { PopCanvas } from "./PopCanvas";
export { PopCanvasV4 } from "./PopCanvasV4";
export * from "./panels";
export * from "./renderers";
export * from "./types";
export * from "./utils/gridUtils";
```
---
## 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`
| 항목 | 내용 |
|------|------|
| 역할 | v5 컴포넌트 편집 패널 |
| 위치 | 오른쪽 사이드바 |
| 역할 | v3 컴포넌트 편집 패널 |
| 용도 | PopPanel 내부에서 사용 |
---
### `frontend/components/pop/designer/panels/ComponentEditorPanelV4.tsx`
| 항목 | 내용 |
|------|------|
| 역할 | v4 오른쪽 속성 패널 |
| 라인 수 | 609줄 |
**핵심 Props**:
```typescript
interface ComponentEditorPanelProps {
component: PopComponentDefinitionV5 | null;
currentMode: GridMode;
onUpdateComponent?: (updates: Partial<PopComponentDefinitionV5>) => void;
interface ComponentEditorPanelV4Props {
component: PopComponentDefinitionV4 | null;
container: PopContainerV4 | null;
onUpdateComponent?: (updates: Partial<PopComponentDefinitionV4>) => void;
onUpdateContainer?: (updates: Partial<PopContainerV4>) => void;
className?: string;
}
```
@ -227,71 +327,115 @@ interface ComponentEditorPanelProps {
| 탭 | 아이콘 | 내용 |
|----|--------|------|
| `grid` | Grid3x3 | 그리드 위치 (col, row, colSpan, rowSpan) |
| `size` | Maximize2 | 크기 제약 (fixed/fill/hug) |
| `settings` | Settings | 라벨, 타입별 설정 |
| `data` | Database | 데이터 바인딩 (Phase 4) |
| `data` | Database | 데이터 바인딩 (미구현) |
**내부 컴포넌트**:
| 컴포넌트 | 역할 |
|----------|------|
| `SizeConstraintForm` | 너비/높이 제약 편집 |
| `SizeButton` | fixed/fill/hug 선택 버튼 |
| `ContainerSettingsForm` | 컨테이너 방향/정렬/간격 편집 |
| `ComponentSettingsForm` | 라벨 편집 |
| `DataBindingPlaceholder` | 데이터 바인딩 플레이스홀더 |
---
### `frontend/components/pop/designer/panels/index.ts`
```typescript
export { default as ComponentEditorPanel, default } from "./ComponentEditorPanel";
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/PopRenderer.tsx`
### `frontend/components/pop/designer/renderers/PopLayoutRenderer.tsx`
| 항목 | 내용 |
|------|------|
| 역할 | v5 레이아웃 CSS Grid 렌더러 |
| 입력 | PopLayoutDataV5, viewportWidth, currentMode |
| 역할 | 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 PopRendererProps {
layout: PopLayoutDataV5;
interface PopFlexRendererProps {
layout: PopLayoutDataV4;
viewportWidth: number;
currentMode?: GridMode;
isDesignMode?: boolean;
selectedComponentId?: string | null;
onComponentClick?: (componentId: string) => void;
onContainerClick?: (containerId: string) => void;
onBackgroundClick?: () => void;
className?: string;
}
```
**CSS Grid 스타일 생성**:
**내부 컴포넌트**:
| 컴포넌트 | 역할 |
|----------|------|
| `ContainerRenderer` | 컨테이너 재귀 렌더링 |
| `ComponentRendererV4` | v4 컴포넌트 렌더링 |
**핵심 함수**:
```typescript
const gridStyle: React.CSSProperties = {
display: "grid",
gridTemplateColumns: `repeat(${breakpoint.columns}, 1fr)`,
gridAutoRows: `${breakpoint.rowHeight}px`,
gap: `${breakpoint.gap}px`,
padding: `${breakpoint.padding}px`,
};
// 반응형 규칙 적용
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
```
**컴포넌트 위치 변환**:
---
```typescript
const convertPosition = (position: PopGridPosition): React.CSSProperties => ({
gridColumn: `${position.col} / span ${position.colSpan}`,
gridRow: `${position.row} / span ${position.rowSpan}`,
});
```
### `frontend/components/pop/designer/renderers/ComponentRenderer.tsx`
| 항목 | 내용 |
|------|------|
| 역할 | 개별 컴포넌트 렌더러 (디자인 모드용) |
---
### `frontend/components/pop/designer/renderers/index.ts`
```typescript
export { default as PopRenderer, default } from "./PopRenderer";
export { PopLayoutRenderer, hasBaseLayout, getEffectiveModeLayout } from "./PopLayoutRenderer";
export { ComponentRenderer } from "./ComponentRenderer";
export { PopFlexRenderer } from "./PopFlexRenderer";
```
---
@ -302,55 +446,85 @@ export { default as PopRenderer, default } from "./PopRenderer";
| 항목 | 내용 |
|------|------|
| 역할 | POP 레이아웃 v5 타입 시스템 |
| 버전 | v5 전용 (레거시 제거됨) |
| 역할 | POP 레이아웃 전체 타입 시스템 |
| 라인 수 | 1442줄 |
**핵심 타입**:
**주요 타입** (v4):
```typescript
// 그리드 모드
type GridMode = "mobile_portrait" | "mobile_landscape" | "tablet_portrait" | "tablet_landscape";
// 그리드 브레이크포인트
interface GridBreakpoint {
label: string;
columns: number;
minWidth: number;
maxWidth: number;
rowHeight: number;
gap: number;
padding: number;
// v4 레이아웃
interface PopLayoutDataV4 {
version: "pop-4.0";
root: PopContainerV4;
components: Record<string, PopComponentDefinitionV4>;
dataFlow: PopDataFlow;
settings: PopGlobalSettingsV4;
metadata?: PopLayoutMetadata;
}
// v5 레이아웃
interface PopLayoutDataV5 {
version: "pop-5.0";
gridConfig: PopGridConfig;
components: Record<string, PopComponentDefinitionV5>;
// 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: PopGlobalSettingsV5;
settings: PopGlobalSettings;
metadata?: PopLayoutMetadata;
overrides?: Record<GridMode, PopModeOverrideV5>;
}
// v3 모드별 레이아웃
interface PopModeLayoutV3 {
componentPositions: Record<string, GridPosition>;
}
// 그리드 위치
interface PopGridPosition {
col: number; // 시작 열 (1부터)
row: number; // 시작 행 (1부터)
colSpan: number; // 열 크기
rowSpan: number; // 행 크기
}
// v5 컴포넌트 정의
interface PopComponentDefinitionV5 {
id: string;
type: PopComponentType;
label?: string;
position: PopGridPosition;
visibility?: { modes: GridMode[]; defaultVisible: boolean };
dataBinding?: PopDataBinding;
style?: PopStylePreset;
config?: PopComponentConfig;
interface GridPosition {
col: number;
row: number;
colSpan: number;
rowSpan: number;
}
```
@ -358,11 +532,21 @@ interface PopComponentDefinitionV5 {
| 함수 | 역할 |
|------|------|
| `createEmptyPopLayoutV5()` | 빈 v5 레이아웃 생성 |
| `addComponentToV5Layout()` | v5에 컴포넌트 추가 |
| `createComponentDefinitionV5()` | v5 컴포넌트 정의 생성 |
| `isV5Layout()` | v5 타입 가드 |
| `detectGridMode()` | 뷰포트 너비로 모드 감지 |
| `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 마이그레이션 |
---
@ -374,32 +558,7 @@ export * from "./pop-layout";
---
## 6. Utils 파일
### `frontend/components/pop/designer/utils/gridUtils.ts`
| 항목 | 내용 |
|------|------|
| 역할 | 그리드 위치 계산 유틸리티 |
| 용도 | 좌표 변환, 겹침 감지, 자동 배치 |
**주요 함수**:
| 함수 | 역할 |
|------|------|
| `convertPositionToMode()` | 12칸 기준 위치를 다른 모드로 변환 |
| `isOverlapping()` | 두 위치 겹침 여부 확인 |
| `resolveOverlaps()` | 겹침 해결 (아래로 밀기) |
| `mouseToGridPosition()` | 마우스 좌표 → 그리드 좌표 |
| `gridToPixelPosition()` | 그리드 좌표 → 픽셀 좌표 |
| `isValidPosition()` | 위치 유효성 검사 |
| `clampPosition()` | 위치 범위 조정 |
| `findNextEmptyPosition()` | 다음 빈 위치 찾기 |
| `autoLayoutComponents()` | 자동 배치 |
---
## 7. Management 파일
## 6. Management 파일
### `frontend/components/pop/management/PopCategoryTree.tsx`
@ -448,7 +607,7 @@ export { PopScreenFlowView } from "./PopScreenFlowView";
---
## 8. Dashboard 파일
## 7. Dashboard 파일
### `frontend/components/pop/dashboard/PopDashboard.tsx`
@ -459,27 +618,134 @@ export { PopScreenFlowView } from "./PopScreenFlowView";
---
### 기타 Dashboard 컴포넌트
### `frontend/components/pop/dashboard/DashboardHeader.tsx`
| 파일 | 역할 |
| 항목 | 내용 |
|------|------|
| `DashboardHeader.tsx` | 상단 헤더 |
| `DashboardFooter.tsx` | 하단 푸터 |
| `MenuGrid.tsx` | 메뉴 그리드 |
| `KpiBar.tsx` | KPI 요약 바 |
| `NoticeBanner.tsx` | 공지 배너 |
| `NoticeList.tsx` | 공지 목록 |
| `ActivityList.tsx` | 최근 활동 목록 |
| 역할 | 상단 헤더 |
| 표시 | 로고, 시간, 사용자 정보 |
---
## 9. Library 파일
### `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 함수**:
@ -491,6 +757,12 @@ async function deletePopScreenGroup(id: number): Promise<...>
async function ensurePopRootGroup(): Promise<...>
```
**유틸리티**:
```typescript
function buildPopGroupTree(groups: PopScreenGroup[]): PopScreenGroup[]
```
---
### `frontend/lib/registry/PopComponentRegistry.ts`
@ -498,6 +770,56 @@ async function ensurePopRootGroup(): Promise<...>
| 항목 | 내용 |
|------|------|
| 역할 | 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
}
```
---
@ -506,11 +828,39 @@ async function ensurePopRootGroup(): Promise<...>
| 항목 | 내용 |
|------|------|
| 역할 | 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>
```
---
## 10. 루트 컴포넌트 파일
## 9. 루트 컴포넌트 파일
### `frontend/components/pop/index.ts`
@ -523,6 +873,24 @@ export * from "./dashboard";
---
### `frontend/components/pop/types.ts`
POP 공통 타입 정의
---
### `frontend/components/pop/data.ts`
POP 샘플/목업 데이터
---
### `frontend/components/pop/styles.css`
POP 전역 스타일
---
### 기타 루트 레벨 컴포넌트
| 파일 | 역할 |
@ -544,28 +912,14 @@ export * from "./dashboard";
| 폴더 | 파일 수 | 설명 |
|------|---------|------|
| `app/(pop)` | 4 | App Router 페이지 |
| `components/pop/designer` | 9 | 디자이너 모듈 (v5) |
| `app/(pop)` | 6 | App Router 페이지 |
| `components/pop/designer` | 12 | 디자이너 모듈 |
| `components/pop/management` | 5 | 관리 모듈 |
| `components/pop/dashboard` | 12 | 대시보드 모듈 |
| `components/pop` (루트) | 15 | 루트 컴포넌트 |
| `lib` | 3 | 라이브러리 |
| **총계** | **48** | |
| **총계** | **53** | |
---
## 삭제된 파일 (v5 통합으로 제거)
| 파일 | 이유 |
|------|------|
| `PopCanvasV4.tsx` | v4 Flexbox 캔버스 |
| `PopFlexRenderer.tsx` | v4 Flexbox 렌더러 |
| `PopLayoutRenderer.tsx` | v3 CSS Grid 렌더러 |
| `ComponentRenderer.tsx` | 레거시 컴포넌트 렌더러 |
| `ComponentEditorPanelV4.tsx` | v4 편집 패널 |
| `PopPanel.tsx` | 레거시 팔레트 패널 |
| `test-v4/page.tsx` | v4 테스트 페이지 |
---
*이 문서는 POP 화면 시스템의 파일 목록을 관리하기 위한 참조용으로 작성되었습니다. (v5 그리드 시스템 기준)*
*이 문서는 POP 화면 시스템의 파일 목록을 관리하기 위한 참조용으로 작성되었습니다.*

View File

@ -1,763 +0,0 @@
# POP 그리드 시스템 코딩 계획
> 작성일: 2026-02-05
> 상태: 코딩 준비 완료
---
## 작업 목록
```
Phase 5.1: 타입 정의 ─────────────────────────────
[ ] 1. v5 타입 정의 (PopLayoutDataV5, PopGridConfig 등)
[ ] 2. 브레이크포인트 상수 정의
[ ] 3. v5 생성/변환 함수
Phase 5.2: 그리드 렌더러 ─────────────────────────
[ ] 4. PopGridRenderer.tsx 생성
[ ] 5. 위치 변환 로직 (12칸→4칸)
Phase 5.3: 디자이너 UI ───────────────────────────
[ ] 6. PopCanvasV5.tsx 생성
[ ] 7. 드래그 스냅 기능
[ ] 8. ComponentEditorPanelV5.tsx
Phase 5.4: 통합 ──────────────────────────────────
[ ] 9. 자동 변환 알고리즘
[ ] 10. PopDesigner.tsx 통합
```
---
## Phase 5.1: 타입 정의
### 작업 1: v5 타입 정의
**파일**: `frontend/components/pop/designer/types/pop-layout.ts`
**추가할 코드**:
```typescript
// ========================================
// v5.0 그리드 기반 레이아웃
// ========================================
// 핵심: CSS Grid로 정확한 위치 지정
// - 열/행 좌표로 배치 (col, row)
// - 칸 단위 크기 (colSpan, rowSpan)
/**
* v5 레이아웃 (그리드 기반)
*/
export interface PopLayoutDataV5 {
version: "pop-5.0";
// 그리드 설정
gridConfig: PopGridConfig;
// 컴포넌트 정의 (ID → 정의)
components: Record<string, PopComponentDefinitionV5>;
// 데이터 흐름 (기존과 동일)
dataFlow: PopDataFlow;
// 전역 설정
settings: PopGlobalSettingsV5;
// 메타데이터
metadata?: PopLayoutMetadata;
// 모드별 오버라이드 (위치 변경용)
overrides?: {
mobile_portrait?: PopModeOverrideV5;
mobile_landscape?: PopModeOverrideV5;
tablet_portrait?: PopModeOverrideV5;
};
}
/**
* 그리드 설정
*/
export interface PopGridConfig {
// 행 높이 (px) - 1행의 기본 높이
rowHeight: number; // 기본 48px
// 간격 (px)
gap: number; // 기본 8px
// 패딩 (px)
padding: number; // 기본 16px
}
/**
* v5 컴포넌트 정의
*/
export interface PopComponentDefinitionV5 {
id: string;
type: PopComponentType;
label?: string;
// 위치 (열/행 좌표) - 기본 모드(태블릿 가로 12칸) 기준
position: PopGridPosition;
// 모드별 표시/숨김
visibility?: {
tablet_landscape?: boolean;
tablet_portrait?: boolean;
mobile_landscape?: boolean;
mobile_portrait?: boolean;
};
// 기존 속성
dataBinding?: PopDataBinding;
style?: PopStylePreset;
config?: PopComponentConfig;
}
/**
* 그리드 위치
*/
export interface PopGridPosition {
col: number; // 시작 열 (1부터, 최대 12)
row: number; // 시작 행 (1부터)
colSpan: number; // 차지할 열 수 (1~12)
rowSpan: number; // 차지할 행 수 (1~)
}
/**
* v5 전역 설정
*/
export interface PopGlobalSettingsV5 {
// 터치 최소 크기 (px)
touchTargetMin: number; // 기본 48
// 모드
mode: "normal" | "industrial";
}
/**
* v5 모드별 오버라이드
*/
export interface PopModeOverrideV5 {
// 컴포넌트별 위치 오버라이드
positions?: Record<string, Partial<PopGridPosition>>;
// 컴포넌트별 숨김
hidden?: string[];
}
```
### 작업 2: 브레이크포인트 상수
**파일**: `frontend/components/pop/designer/types/pop-layout.ts`
```typescript
// ========================================
// 그리드 브레이크포인트
// ========================================
export type GridMode =
| "mobile_portrait"
| "mobile_landscape"
| "tablet_portrait"
| "tablet_landscape";
export const GRID_BREAKPOINTS: Record<GridMode, {
minWidth?: number;
maxWidth?: number;
columns: number;
rowHeight: number;
gap: number;
padding: number;
label: string;
}> = {
// 4~6인치 모바일 세로
mobile_portrait: {
maxWidth: 599,
columns: 4,
rowHeight: 40,
gap: 8,
padding: 12,
label: "모바일 세로 (4칸)",
},
// 6~8인치 모바일 가로 / 작은 태블릿
mobile_landscape: {
minWidth: 600,
maxWidth: 839,
columns: 6,
rowHeight: 44,
gap: 8,
padding: 16,
label: "모바일 가로 (6칸)",
},
// 8~10인치 태블릿 세로
tablet_portrait: {
minWidth: 840,
maxWidth: 1023,
columns: 8,
rowHeight: 48,
gap: 12,
padding: 16,
label: "태블릿 세로 (8칸)",
},
// 10~14인치 태블릿 가로 (기본)
tablet_landscape: {
minWidth: 1024,
columns: 12,
rowHeight: 48,
gap: 16,
padding: 24,
label: "태블릿 가로 (12칸)",
},
};
// 기본 모드
export const DEFAULT_GRID_MODE: GridMode = "tablet_landscape";
// 뷰포트 너비로 모드 감지
export function detectGridMode(viewportWidth: number): GridMode {
if (viewportWidth < 600) return "mobile_portrait";
if (viewportWidth < 840) return "mobile_landscape";
if (viewportWidth < 1024) return "tablet_portrait";
return "tablet_landscape";
}
```
### 작업 3: v5 생성/변환 함수
**파일**: `frontend/components/pop/designer/types/pop-layout.ts`
```typescript
// ========================================
// v5 유틸리티 함수
// ========================================
/**
* 빈 v5 레이아웃 생성
*/
export const createEmptyPopLayoutV5 = (): PopLayoutDataV5 => ({
version: "pop-5.0",
gridConfig: {
rowHeight: 48,
gap: 8,
padding: 16,
},
components: {},
dataFlow: { connections: [] },
settings: {
touchTargetMin: 48,
mode: "normal",
},
});
/**
* v5 레이아웃 여부 확인
*/
export const isV5Layout = (layout: any): layout is PopLayoutDataV5 => {
return layout?.version === "pop-5.0";
};
/**
* v5 컴포넌트 정의 생성
*/
export const createComponentDefinitionV5 = (
id: string,
type: PopComponentType,
position: PopGridPosition,
label?: string
): PopComponentDefinitionV5 => ({
id,
type,
label,
position,
});
/**
* 컴포넌트 타입별 기본 크기 (칸 단위)
*/
export const DEFAULT_COMPONENT_SIZE: Record<PopComponentType, { colSpan: number; rowSpan: number }> = {
"pop-field": { colSpan: 3, rowSpan: 1 },
"pop-button": { colSpan: 2, rowSpan: 1 },
"pop-list": { colSpan: 12, rowSpan: 4 },
"pop-indicator": { colSpan: 3, rowSpan: 2 },
"pop-scanner": { colSpan: 6, rowSpan: 2 },
"pop-numpad": { colSpan: 4, rowSpan: 5 },
"pop-spacer": { colSpan: 1, rowSpan: 1 },
"pop-break": { colSpan: 12, rowSpan: 0 },
};
/**
* v4 → v5 마이그레이션
*/
export const migrateV4ToV5 = (layoutV4: PopLayoutDataV4): PopLayoutDataV5 => {
const componentsV4 = Object.values(layoutV4.components);
const componentsV5: Record<string, PopComponentDefinitionV5> = {};
// Flexbox 순서 → Grid 위치 변환
let currentRow = 1;
let currentCol = 1;
const columns = 12;
componentsV4.forEach((comp) => {
// 픽셀 → 칸 변환 (대략적)
const colSpan = comp.size.width === "fill"
? columns
: Math.max(1, Math.min(12, Math.round((comp.size.fixedWidth || 100) / 85)));
const rowSpan = Math.max(1, Math.round((comp.size.fixedHeight || 48) / 48));
// 줄바꿈 체크
if (currentCol + colSpan - 1 > columns) {
currentRow += 1;
currentCol = 1;
}
componentsV5[comp.id] = {
id: comp.id,
type: comp.type,
label: comp.label,
position: {
col: currentCol,
row: currentRow,
colSpan,
rowSpan,
},
visibility: comp.visibility,
dataBinding: comp.dataBinding,
config: comp.config,
};
currentCol += colSpan;
});
return {
version: "pop-5.0",
gridConfig: {
rowHeight: 48,
gap: layoutV4.settings.defaultGap,
padding: layoutV4.settings.defaultPadding,
},
components: componentsV5,
dataFlow: layoutV4.dataFlow,
settings: {
touchTargetMin: layoutV4.settings.touchTargetMin,
mode: layoutV4.settings.mode,
},
};
};
```
---
## Phase 5.2: 그리드 렌더러
### 작업 4: PopGridRenderer.tsx
**파일**: `frontend/components/pop/designer/renderers/PopGridRenderer.tsx`
```typescript
"use client";
import React, { useMemo } from "react";
import { cn } from "@/lib/utils";
import {
PopLayoutDataV5,
PopComponentDefinitionV5,
PopGridPosition,
GridMode,
GRID_BREAKPOINTS,
detectGridMode,
} from "../types/pop-layout";
interface PopGridRendererProps {
layout: PopLayoutDataV5;
viewportWidth: number;
currentMode?: GridMode;
isDesignMode?: boolean;
selectedComponentId?: string | null;
onComponentClick?: (componentId: string) => void;
onBackgroundClick?: () => void;
className?: string;
}
export function PopGridRenderer({
layout,
viewportWidth,
currentMode,
isDesignMode = false,
selectedComponentId,
onComponentClick,
onBackgroundClick,
className,
}: PopGridRendererProps) {
const { gridConfig, components, overrides } = layout;
// 현재 모드 (자동 감지 또는 지정)
const mode = currentMode || detectGridMode(viewportWidth);
const breakpoint = GRID_BREAKPOINTS[mode];
// CSS Grid 스타일
const gridStyle = useMemo((): React.CSSProperties => ({
display: "grid",
gridTemplateColumns: `repeat(${breakpoint.columns}, 1fr)`,
gridAutoRows: `${breakpoint.rowHeight}px`,
gap: `${breakpoint.gap}px`,
padding: `${breakpoint.padding}px`,
minHeight: "100%",
}), [breakpoint]);
// visibility 체크
const isVisible = (comp: PopComponentDefinitionV5): boolean => {
if (!comp.visibility) return true;
return comp.visibility[mode] !== false;
};
// 위치 변환 (12칸 기준 → 현재 모드 칸 수)
const convertPosition = (position: PopGridPosition): React.CSSProperties => {
const sourceColumns = 12; // 항상 12칸 기준으로 저장
const targetColumns = breakpoint.columns;
if (sourceColumns === targetColumns) {
return {
gridColumn: `${position.col} / span ${position.colSpan}`,
gridRow: `${position.row} / span ${position.rowSpan}`,
};
}
// 비율 계산
const ratio = targetColumns / sourceColumns;
let newCol = Math.max(1, Math.ceil((position.col - 1) * ratio) + 1);
let newColSpan = Math.max(1, Math.round(position.colSpan * ratio));
// 범위 초과 방지
if (newCol + newColSpan - 1 > targetColumns) {
newColSpan = targetColumns - newCol + 1;
}
return {
gridColumn: `${newCol} / span ${Math.max(1, newColSpan)}`,
gridRow: `${position.row} / span ${position.rowSpan}`,
};
};
// 오버라이드 적용
const getEffectivePosition = (comp: PopComponentDefinitionV5): PopGridPosition => {
const override = overrides?.[mode]?.positions?.[comp.id];
if (override) {
return { ...comp.position, ...override };
}
return comp.position;
};
return (
<div
className={cn("relative min-h-full w-full bg-white", className)}
style={gridStyle}
onClick={(e) => {
if (e.target === e.currentTarget) {
onBackgroundClick?.();
}
}}
>
{Object.values(components).map((comp) => {
if (!isVisible(comp)) return null;
const position = getEffectivePosition(comp);
const positionStyle = convertPosition(position);
return (
<div
key={comp.id}
className={cn(
"relative rounded-lg border-2 bg-white transition-all overflow-hidden",
selectedComponentId === comp.id
? "border-primary ring-2 ring-primary/30 z-10"
: "border-gray-200",
isDesignMode && "cursor-pointer hover:border-gray-300"
)}
style={positionStyle}
onClick={(e) => {
e.stopPropagation();
onComponentClick?.(comp.id);
}}
>
{/* 컴포넌트 내용 */}
<ComponentContent component={comp} isDesignMode={isDesignMode} />
</div>
);
})}
</div>
);
}
// 컴포넌트 내용 렌더링
function ComponentContent({
component,
isDesignMode
}: {
component: PopComponentDefinitionV5;
isDesignMode: boolean;
}) {
const typeLabels: Record<string, string> = {
"pop-field": "필드",
"pop-button": "버튼",
"pop-list": "리스트",
"pop-indicator": "인디케이터",
"pop-scanner": "스캐너",
"pop-numpad": "숫자패드",
"pop-spacer": "스페이서",
"pop-break": "줄바꿈",
};
if (isDesignMode) {
return (
<div className="flex h-full w-full flex-col">
<div className="flex h-5 shrink-0 items-center border-b bg-gray-50 px-2">
<span className="text-[10px] font-medium text-gray-600">
{component.label || typeLabels[component.type] || component.type}
</span>
</div>
<div className="flex flex-1 items-center justify-center p-2">
<span className="text-xs text-gray-400">
{typeLabels[component.type]}
</span>
</div>
</div>
);
}
// 실제 컴포넌트 렌더링 (Phase 4에서 구현)
return (
<div className="flex h-full w-full items-center justify-center p-2">
<span className="text-xs text-gray-500">
{component.label || typeLabels[component.type]}
</span>
</div>
);
}
export default PopGridRenderer;
```
### 작업 5: 위치 변환 유틸리티
**파일**: `frontend/components/pop/designer/utils/gridUtils.ts`
```typescript
import { PopGridPosition, GridMode, GRID_BREAKPOINTS } from "../types/pop-layout";
/**
* 12칸 기준 위치를 다른 모드로 변환
*/
export function convertPositionToMode(
position: PopGridPosition,
targetMode: GridMode
): PopGridPosition {
const sourceColumns = 12;
const targetColumns = GRID_BREAKPOINTS[targetMode].columns;
if (sourceColumns === targetColumns) {
return position;
}
const ratio = targetColumns / sourceColumns;
// 열 위치 변환
let newCol = Math.max(1, Math.ceil((position.col - 1) * ratio) + 1);
let newColSpan = Math.max(1, Math.round(position.colSpan * ratio));
// 범위 초과 방지
if (newCol > targetColumns) {
newCol = 1;
}
if (newCol + newColSpan - 1 > targetColumns) {
newColSpan = targetColumns - newCol + 1;
}
return {
col: newCol,
row: position.row,
colSpan: Math.max(1, newColSpan),
rowSpan: position.rowSpan,
};
}
/**
* 두 위치가 겹치는지 확인
*/
export function isOverlapping(a: PopGridPosition, b: PopGridPosition): boolean {
// 열 겹침
const colOverlap = !(a.col + a.colSpan - 1 < b.col || b.col + b.colSpan - 1 < a.col);
// 행 겹침
const rowOverlap = !(a.row + a.rowSpan - 1 < b.row || b.row + b.rowSpan - 1 < a.row);
return colOverlap && rowOverlap;
}
/**
* 겹침 해결 (아래로 밀기)
*/
export function resolveOverlaps(
positions: Array<{ id: string; position: PopGridPosition }>,
columns: number
): Array<{ id: string; position: PopGridPosition }> {
// row, col 순으로 정렬
const sorted = [...positions].sort((a, b) =>
a.position.row - b.position.row || a.position.col - b.position.col
);
const resolved: Array<{ id: string; position: PopGridPosition }> = [];
sorted.forEach((item) => {
let { row, col, colSpan, rowSpan } = item.position;
// 기존 배치와 겹치면 아래로 이동
let attempts = 0;
while (attempts < 100) {
const currentPos: PopGridPosition = { col, row, colSpan, rowSpan };
const hasOverlap = resolved.some(r => isOverlapping(currentPos, r.position));
if (!hasOverlap) break;
row++;
attempts++;
}
resolved.push({
id: item.id,
position: { col, row, colSpan, rowSpan },
});
});
return resolved;
}
/**
* 마우스 좌표 → 그리드 좌표 변환
*/
export function mouseToGridPosition(
mouseX: number,
mouseY: number,
canvasRect: DOMRect,
columns: number,
rowHeight: number,
gap: number,
padding: number
): { col: number; row: number } {
// 캔버스 내 상대 위치
const relX = mouseX - canvasRect.left - padding;
const relY = mouseY - canvasRect.top - padding;
// 칸 너비 계산
const totalGap = gap * (columns - 1);
const colWidth = (canvasRect.width - padding * 2 - totalGap) / columns;
// 그리드 좌표 계산 (1부터 시작)
const col = Math.max(1, Math.min(columns, Math.floor(relX / (colWidth + gap)) + 1));
const row = Math.max(1, Math.floor(relY / (rowHeight + gap)) + 1);
return { col, row };
}
/**
* 그리드 좌표 → 픽셀 좌표 변환
*/
export function gridToPixelPosition(
col: number,
row: number,
canvasWidth: number,
columns: number,
rowHeight: number,
gap: number,
padding: number
): { x: number; y: number; width: number; height: number } {
const totalGap = gap * (columns - 1);
const colWidth = (canvasWidth - padding * 2 - totalGap) / columns;
return {
x: padding + (col - 1) * (colWidth + gap),
y: padding + (row - 1) * (rowHeight + gap),
width: colWidth,
height: rowHeight,
};
}
```
---
## Phase 5.3: 디자이너 UI
### 작업 6-7: PopCanvasV5.tsx
**파일**: `frontend/components/pop/designer/PopCanvasV5.tsx`
핵심 기능:
- 그리드 배경 표시 (바둑판)
- 4개 모드 프리셋 버튼
- 드래그 앤 드롭 (칸에 스냅)
- 컴포넌트 리사이즈 (칸 단위)
### 작업 8: ComponentEditorPanelV5.tsx
**파일**: `frontend/components/pop/designer/panels/ComponentEditorPanelV5.tsx`
핵심 기능:
- 위치 편집 (col, row 입력)
- 크기 편집 (colSpan, rowSpan 입력)
- visibility 체크박스
---
## Phase 5.4: 통합
### 작업 9: 자동 변환 알고리즘
이미 `gridUtils.ts`에 포함
### 작업 10: PopDesigner.tsx 통합
**수정 파일**: `frontend/components/pop/designer/PopDesigner.tsx`
변경 사항:
- v5 레이아웃 상태 추가
- v3/v4/v5 자동 판별
- 새 화면 → v5로 시작
- v4 → v5 마이그레이션 옵션
---
## 파일 목록
| 상태 | 파일 | 작업 |
|------|------|------|
| 수정 | `types/pop-layout.ts` | v5 타입, 상수, 함수 추가 |
| 생성 | `renderers/PopGridRenderer.tsx` | 그리드 렌더러 |
| 생성 | `utils/gridUtils.ts` | 유틸리티 함수 |
| 생성 | `PopCanvasV5.tsx` | 그리드 캔버스 |
| 생성 | `panels/ComponentEditorPanelV5.tsx` | 속성 패널 |
| 수정 | `PopDesigner.tsx` | v5 통합 |
---
## 시작 순서
```
1. pop-layout.ts에 v5 타입 추가 (작업 1-3)
2. PopGridRenderer.tsx 생성 (작업 4)
3. gridUtils.ts 생성 (작업 5)
4. PopCanvasV5.tsx 생성 (작업 6-7)
5. ComponentEditorPanelV5.tsx 생성 (작업 8)
6. PopDesigner.tsx 수정 (작업 9-10)
7. 테스트
```
---
*다음 단계: Phase 5.1 작업 1 시작 (v5 타입 정의)*

View File

@ -1,329 +0,0 @@
# POP 화면 그리드 시스템 설계
> 작성일: 2026-02-05
> 상태: 계획 (Plan)
> 관련: Softr, Ant Design, Material Design 분석 기반
---
## 1. 목적
POP 화면의 반응형 레이아웃을 **일관성 있고 예측 가능하게** 만들기 위한 그리드 시스템 설계
### 현재 문제
- 픽셀 단위 자유 배치 → 화면 크기별로 깨짐
- 컴포넌트 크기 규칙 없음 → 디자인 불일치
- 반응형 규칙 부족 → 모드별 수동 조정 필요
### 목표
- 그리드 기반 배치로 일관성 확보
- 크기 프리셋으로 디자인 통일
- 자동 반응형으로 작업량 감소
---
## 2. 대상 디바이스
### 지원 범위
| 구분 | 크기 범위 | 기준 해상도 | 비고 |
|------|----------|-------------|------|
| 모바일 | 4~8인치 | 375x667 (세로) | 산업용 PDA 포함 |
| 태블릿 | 8~14인치 | 1024x768 (가로) | 기본 기준 |
### 참고: 산업용 디바이스 해상도
| 디바이스 | 화면 크기 | 해상도 |
|----------|----------|--------|
| Zebra TC57 PDA | 5인치 | 720x1280 |
| Honeywell CT47 | 5.5인치 | 2160x1080 |
| Honeywell RT10A | 10.1인치 | 1920x1200 |
---
## 3. 그리드 시스템 설계
### 3.1 브레이크포인트 (Breakpoints)
Material Design 가이드라인 기반으로 4단계 정의:
| 모드 | 약어 | 너비 범위 | 대표 디바이스 | 그리드 칸 수 |
|------|------|----------|---------------|-------------|
| 모바일 세로 | `mp` | ~599px | 4~6인치 폰 | **4 columns** |
| 모바일 가로 | `ml` | 600~839px | 폰 가로, 7인치 태블릿 | **6 columns** |
| 태블릿 세로 | `tp` | 840~1023px | 8~10인치 태블릿 세로 | **8 columns** |
| 태블릿 가로 | `tl` | 1024px~ | 10~14인치 태블릿 가로 | **12 columns** |
### 3.2 기준 해상도
| 모드 | 기준 너비 | 기준 높이 | 비고 |
|------|----------|----------|------|
| 모바일 세로 | 375px | 667px | iPhone SE 기준 |
| 모바일 가로 | 667px | 375px | - |
| 태블릿 세로 | 768px | 1024px | iPad 기준 |
| **태블릿 가로** | **1024px** | **768px** | **기본 설계 모드** |
### 3.3 그리드 구조
```
태블릿 가로 (12 columns)
┌──────────────────────────────────────────────────────────────┐
│ ← 16px →│ Col │ 16px │ Col │ 16px │ ... │ Col │← 16px →│
│ margin │ 1 │ gap │ 2 │ gap │ │ 12 │ margin │
└──────────────────────────────────────────────────────────────┘
모바일 세로 (4 columns)
┌────────────────────────┐
│← 16px →│ Col │ 8px │ Col │ 8px │ Col │ 8px │ Col │← 16px →│
│ margin │ 1 │ gap │ 2 │ gap │ 3 │ gap │ 4 │ margin │
└────────────────────────┘
```
### 3.4 마진/간격 규칙
8px 기반 간격 시스템 (Material Design 표준):
| 속성 | 태블릿 | 모바일 | 용도 |
|------|--------|--------|------|
| screenPadding | 24px | 16px | 화면 가장자리 여백 |
| gapSm | 8px | 8px | 컴포넌트 사이 최소 간격 |
| gapMd | 16px | 12px | 기본 간격 |
| gapLg | 24px | 16px | 섹션 간 간격 |
| rowGap | 16px | 12px | 줄 사이 간격 |
---
## 4. 컴포넌트 크기 시스템
### 4.1 열 단위 (Span) 크기
픽셀 대신 **열 단위(span)** 로 크기 지정:
| 크기 | 태블릿 가로 (12col) | 태블릿 세로 (8col) | 모바일 (4col) |
|------|--------------------|--------------------|---------------|
| XS | 1 span | 1 span | 1 span |
| S | 2 span | 2 span | 2 span |
| M | 3 span | 2 span | 2 span |
| L | 4 span | 4 span | 4 span (full) |
| XL | 6 span | 4 span | 4 span (full) |
| Full | 12 span | 8 span | 4 span |
### 4.2 높이 프리셋
| 프리셋 | 픽셀값 | 용도 |
|--------|--------|------|
| `xs` | 32px | 배지, 아이콘 버튼 |
| `sm` | 48px | 일반 버튼, 입력 필드 |
| `md` | 80px | 카드, 인디케이터 |
| `lg` | 120px | 큰 카드, 리스트 아이템 |
| `xl` | 200px | 대형 영역 |
| `auto` | 내용 기반 | 가변 높이 |
### 4.3 컴포넌트별 기본값
| 컴포넌트 | 태블릿 span | 모바일 span | 높이 | 비고 |
|----------|------------|-------------|------|------|
| pop-field | 3 (M) | 2 (S) | sm | 입력/표시 |
| pop-button | 2 (S) | 2 (S) | sm | 액션 버튼 |
| pop-list | 12 (Full) | 4 (Full) | auto | 데이터 목록 |
| pop-indicator | 3 (M) | 2 (S) | md | KPI 표시 |
| pop-scanner | 6 (XL) | 4 (Full) | lg | 스캔 영역 |
| pop-numpad | 6 (XL) | 4 (Full) | auto | 숫자 패드 |
| pop-spacer | 1 (XS) | 1 (XS) | - | 빈 공간 |
| pop-break | Full | Full | 0 | 줄바꿈 |
---
## 5. 반응형 규칙
### 5.1 자동 조정
설계자가 별도 설정하지 않아도 자동 적용:
```
태블릿 가로 (12col): [A:3] [B:3] [C:3] [D:3] → 한 줄
태블릿 세로 (8col): [A:2] [B:2] [C:2] [D:2] → 한 줄
모바일 (4col): [A:2] [B:2] → 두 줄
[C:2] [D:2]
```
### 5.2 수동 오버라이드
필요시 모드별 설정 가능:
```typescript
interface ResponsiveOverride {
// 크기 변경
span?: number;
height?: HeightPreset;
// 표시/숨김
hidden?: boolean;
// 내부 요소 숨김 (컴포넌트별)
hideElements?: string[];
}
```
### 5.3 표시/숨김 예시
```
태블릿: [제품명] [수량] [단가] [합계] [비고]
모바일: [제품명] [수량] [비고] ← 단가, 합계 숨김
```
설정:
```typescript
{
id: "unit-price",
type: "pop-field",
visibility: {
mobile_portrait: false,
mobile_landscape: false
}
}
```
---
## 6. 데이터 구조 (제안)
### 6.1 레이아웃 데이터 (v5 제안)
```typescript
interface PopLayoutDataV5 {
version: "5.0";
// 그리드 설정 (전역)
gridConfig: {
tablet: { columns: 12; gap: 16; padding: 24 };
mobile: { columns: 4; gap: 8; padding: 16 };
};
// 컴포넌트 목록 (순서대로)
components: PopComponentV5[];
// 모드별 오버라이드 (선택)
modeOverrides?: {
[mode: string]: {
gridConfig?: Partial<GridConfig>;
componentOverrides?: Record<string, ResponsiveOverride>;
};
};
}
```
### 6.2 컴포넌트 데이터
```typescript
interface PopComponentV5 {
id: string;
type: PopComponentType;
// 크기 (span 단위)
size: {
span: number; // 기본 열 개수 (1~12)
height: HeightPreset; // xs, sm, md, lg, xl, auto
};
// 반응형 크기 (선택)
responsiveSize?: {
mobile?: { span?: number; height?: HeightPreset };
tablet_portrait?: { span?: number; height?: HeightPreset };
};
// 표시/숨김
visibility?: {
[mode: string]: boolean;
};
// 컴포넌트별 설정
config?: any;
// 데이터 바인딩
dataBinding?: any;
}
```
---
## 7. 현재 v4와의 관계
### 7.1 v4 유지 사항
- Flexbox 기반 렌더링
- 오버라이드 시스템
- visibility 속성
### 7.2 변경 사항
| v4 | v5 (제안) |
|----|-----------|
| `fixedWidth: number` | `span: 1~12` |
| `minWidth`, `maxWidth` | 그리드 기반 자동 계산 |
| 자유 픽셀 | 열 단위 프리셋 |
### 7.3 마이그레이션 방향
```
v4 fixedWidth: 200px
v5 span: 3 (태블릿 기준 약 25%)
```
---
## 8. 구현 우선순위
### Phase 1: 프리셋만 적용 (최소 변경)
- [ ] 높이 프리셋 드롭다운
- [ ] 너비 프리셋 드롭다운 (XS~Full)
- [ ] 기존 Flexbox 렌더링 유지
### Phase 2: 그리드 시스템 도입
- [ ] 브레이크포인트 감지
- [ ] 그리드 칸 수 자동 변경
- [ ] span → 픽셀 자동 계산
### Phase 3: 반응형 자동화
- [ ] 모드별 자동 span 변환
- [ ] 줄바꿈 자동 처리
- [ ] 오버라이드 최소화
---
## 9. 참고 자료
### 분석 대상
| 도구 | 핵심 특징 | 적용 가능 요소 |
|------|----------|---------------|
| **Softr** | 블록 기반, 제약 기반 레이아웃 | 컨테이너 슬롯 방식 |
| **Ant Design** | 24열 그리드, 8px 간격 | 그리드 시스템, 간격 규칙 |
| **Material Design** | 4/8/12열, 반응형 브레이크포인트 | 디바이스별 칸 수 |
### 핵심 원칙
1. **Flexbox는 도구**: 그리드 시스템 안에서 사용
2. **제약은 자유**: 규칙이 있어야 일관된 디자인 가능
3. **최소 설정, 최대 효과**: 기본값이 좋으면 오버라이드 불필요
---
## 10. FAQ
### Q1: 기존 v4 화면은 어떻게 되나요?
A: 하위 호환 유지. v4 화면은 v4로 계속 동작.
### Q2: 컴포넌트를 그리드 칸 사이에 배치할 수 있나요?
A: 아니요. 칸 단위로만 배치. 이게 일관성의 핵심.
### Q3: 그리드 칸 수를 바꿀 수 있나요?
A: 기본값(4/6/8/12) 권장. 필요시 프로젝트 레벨 설정 가능.
### Q4: Flexbox와 Grid 중 뭘 쓰나요?
A: 둘 다. Grid로 칸 나누고, Flexbox로 칸 안에서 정렬.
---
*이 문서는 계획 단계이며, 실제 구현 시 수정될 수 있습니다.*
*최종 업데이트: 2026-02-05*

View File

@ -1,480 +0,0 @@
# POP 그리드 시스템 도입 계획
> 작성일: 2026-02-05
> 상태: 계획 승인, 구현 대기
---
## 개요
### 목표
현재 Flexbox 기반 v4 시스템을 **CSS Grid 기반 v5 시스템**으로 전환하여
4~14인치 화면에서 일관된 배치와 예측 가능한 반응형 레이아웃 구현
### 핵심 변경점
| 항목 | v4 (현재) | v5 (그리드) |
|------|----------|-------------|
| 배치 방식 | Flexbox 흐름 | **Grid 위치 지정** |
| 크기 단위 | 픽셀 (200px) | **칸 (col, row)** |
| 위치 지정 | 순서대로 자동 | **열/행 좌표** |
| 줄바꿈 | 자동 (넘치면) | **명시적 (row 지정)** |
---
## Phase 구조
```
[Phase 5.1] [Phase 5.2] [Phase 5.3] [Phase 5.4]
그리드 타입 정의 → 그리드 렌더러 → 디자이너 UI → 반응형 자동화
1주 1주 1~2주 1주
```
---
## Phase 5.1: 그리드 타입 정의
### 목표
v5 레이아웃 데이터 구조 설계
### 작업 항목
- [ ] `PopLayoutDataV5` 인터페이스 정의
- [ ] `PopGridConfig` 인터페이스 (그리드 설정)
- [ ] `PopComponentPositionV5` 인터페이스 (위치: col, row, colSpan, rowSpan)
- [ ] `PopSizeConstraintV5` 인터페이스 (칸 기반 크기)
- [ ] 브레이크포인트 상수 정의
- [ ] `createEmptyPopLayoutV5()` 생성 함수
- [ ] `isV5Layout()` 타입 가드
### 데이터 구조 설계
```typescript
// v5 레이아웃
interface PopLayoutDataV5 {
version: "pop-5.0";
// 그리드 설정
gridConfig: PopGridConfig;
// 컴포넌트 목록
components: Record<string, PopComponentDefinitionV5>;
// 모드별 오버라이드
overrides?: {
mobile_portrait?: PopModeOverrideV5;
mobile_landscape?: PopModeOverrideV5;
tablet_portrait?: PopModeOverrideV5;
};
// 기존 호환
dataFlow: PopDataFlow;
settings: PopGlobalSettingsV5;
}
// 그리드 설정
interface PopGridConfig {
// 모드별 칸 수
columns: {
tablet_landscape: 12; // 기본 (10~14인치)
tablet_portrait: 8; // 8~10인치 세로
mobile_landscape: 6; // 6~8인치 가로
mobile_portrait: 4; // 4~6인치 세로
};
// 행 높이 (px) - 1행의 기본 높이
rowHeight: number; // 기본 48px
// 간격
gap: number; // 기본 8px
padding: number; // 기본 16px
}
// 컴포넌트 정의
interface PopComponentDefinitionV5 {
id: string;
type: PopComponentType;
label?: string;
// 위치 (열/행 좌표) - 기본 모드(태블릿 가로) 기준
position: {
col: number; // 시작 열 (1부터)
row: number; // 시작 행 (1부터)
colSpan: number; // 차지할 열 수 (1~12)
rowSpan: number; // 차지할 행 수 (1~)
};
// 모드별 표시/숨김
visibility?: {
tablet_landscape?: boolean;
tablet_portrait?: boolean;
mobile_landscape?: boolean;
mobile_portrait?: boolean;
};
// 기존 속성
dataBinding?: PopDataBinding;
config?: PopComponentConfig;
}
```
### 브레이크포인트 정의
```typescript
// 브레이크포인트 상수
const GRID_BREAKPOINTS = {
// 4~6인치 모바일 세로
mobile_portrait: {
maxWidth: 599,
columns: 4,
rowHeight: 40,
gap: 8,
padding: 12,
},
// 6~8인치 모바일 가로 / 작은 태블릿
mobile_landscape: {
minWidth: 600,
maxWidth: 839,
columns: 6,
rowHeight: 44,
gap: 8,
padding: 16,
},
// 8~10인치 태블릿 세로
tablet_portrait: {
minWidth: 840,
maxWidth: 1023,
columns: 8,
rowHeight: 48,
gap: 12,
padding: 16,
},
// 10~14인치 태블릿 가로 (기본)
tablet_landscape: {
minWidth: 1024,
columns: 12,
rowHeight: 48,
gap: 16,
padding: 24,
},
} as const;
```
### 산출물
- `frontend/components/pop/designer/types/pop-layout-v5.ts`
---
## Phase 5.2: 그리드 렌더러
### 목표
CSS Grid 기반 렌더러 구현
### 작업 항목
- [ ] `PopGridRenderer.tsx` 생성
- [ ] CSS Grid 스타일 계산 로직
- [ ] 브레이크포인트 감지 및 칸 수 자동 변경
- [ ] 컴포넌트 위치 렌더링 (grid-column, grid-row)
- [ ] 모드별 자동 위치 재계산 (12칸→4칸 변환)
- [ ] visibility 처리
- [ ] 기존 PopFlexRenderer와 공존
### 렌더링 로직
```typescript
// CSS Grid 스타일 생성
function calculateGridStyle(config: PopGridConfig, mode: string): React.CSSProperties {
const columns = config.columns[mode];
const { rowHeight, gap, padding } = config;
return {
display: 'grid',
gridTemplateColumns: `repeat(${columns}, 1fr)`,
gridAutoRows: `${rowHeight}px`,
gap: `${gap}px`,
padding: `${padding}px`,
};
}
// 컴포넌트 위치 스타일
function calculatePositionStyle(
position: PopComponentPositionV5['position'],
sourceColumns: number, // 원본 모드 칸 수 (12)
targetColumns: number // 현재 모드 칸 수 (4)
): React.CSSProperties {
// 12칸 → 4칸 변환 예시
// col: 7, colSpan: 3 → col: 3, colSpan: 1
const ratio = targetColumns / sourceColumns;
const newCol = Math.max(1, Math.ceil(position.col * ratio));
const newColSpan = Math.max(1, Math.round(position.colSpan * ratio));
return {
gridColumn: `${newCol} / span ${Math.min(newColSpan, targetColumns - newCol + 1)}`,
gridRow: `${position.row} / span ${position.rowSpan}`,
};
}
```
### 산출물
- `frontend/components/pop/designer/renderers/PopGridRenderer.tsx`
---
## Phase 5.3: 디자이너 UI
### 목표
그리드 기반 편집 UI 구현
### 작업 항목
- [ ] `PopCanvasV5.tsx` 생성 (그리드 캔버스)
- [ ] 그리드 배경 표시 (바둑판 모양)
- [ ] 컴포넌트 드래그 배치 (칸에 스냅)
- [ ] 컴포넌트 리사이즈 (칸 단위)
- [ ] 위치 편집 패널 (col, row, colSpan, rowSpan)
- [ ] 모드 전환 시 그리드 칸 수 변경 표시
- [ ] v4/v5 자동 판별 및 전환
### UI 구조
```
┌─────────────────────────────────────────────────────────────────┐
│ ← 목록 화면명 *변경됨 [↶][↷] 그리드 레이아웃 (v5) [저장] │
├─────────────────────────────────────────────────────────────────┤
│ 미리보기: [모바일↕ 4칸] [모바일↔ 6칸] [태블릿↕ 8칸] [태블릿↔ 12칸] │
├────────────┬────────────────────────────────────┬───────────────┤
│ │ 1 2 3 4 5 6 ... 12 │ │
│ 컴포넌트 │ ┌───────────┬───────────┐ │ 위치 │
│ │1│ A │ B │ │ 열: [1-12] │
│ 필드 │ ├───────────┴───────────┤ │ 행: [1-99] │
│ 버튼 │2│ C │ │ 너비: [1-12]│
│ 리스트 │ ├───────────┬───────────┤ │ 높이: [1-10]│
│ 인디케이터 │3│ D │ E │ │ │
│ ... │ └───────────┴───────────┘ │ 표시 설정 │
│ │ │ [x] 태블릿↔ │
│ │ (그리드 배경 표시) │ [x] 모바일↕ │
└────────────┴────────────────────────────────────┴───────────────┘
```
### 드래그 앤 드롭 로직
```typescript
// 마우스 위치 → 그리드 좌표 변환
function mouseToGridPosition(
mouseX: number,
mouseY: number,
gridConfig: PopGridConfig,
canvasRect: DOMRect
): { col: number; row: number } {
const { columns, rowHeight, gap, padding } = gridConfig;
// 캔버스 내 상대 위치
const relX = mouseX - canvasRect.left - padding;
const relY = mouseY - canvasRect.top - padding;
// 칸 너비 계산
const totalGap = gap * (columns - 1);
const colWidth = (canvasRect.width - padding * 2 - totalGap) / columns;
// 그리드 좌표 계산
const col = Math.max(1, Math.min(columns, Math.floor(relX / (colWidth + gap)) + 1));
const row = Math.max(1, Math.floor(relY / (rowHeight + gap)) + 1);
return { col, row };
}
```
### 산출물
- `frontend/components/pop/designer/PopCanvasV5.tsx`
- `frontend/components/pop/designer/panels/ComponentEditorPanelV5.tsx`
---
## Phase 5.4: 반응형 자동화
### 목표
모드 전환 시 자동 레이아웃 조정
### 작업 항목
- [ ] 12칸 → 4칸 자동 변환 알고리즘
- [ ] 겹침 감지 및 자동 재배치
- [ ] 모드별 오버라이드 저장
- [ ] "자동 배치" vs "수동 고정" 선택
- [ ] 변환 미리보기
### 자동 변환 알고리즘
```typescript
// 12칸 → 4칸 변환 전략
function convertLayoutToMode(
components: PopComponentDefinitionV5[],
sourceMode: 'tablet_landscape', // 12칸
targetMode: 'mobile_portrait' // 4칸
): PopComponentDefinitionV5[] {
const sourceColumns = 12;
const targetColumns = 4;
const ratio = targetColumns / sourceColumns; // 0.333
// 1. 각 컴포넌트 위치 변환
const converted = components.map(comp => {
const newCol = Math.max(1, Math.ceil(comp.position.col * ratio));
const newColSpan = Math.max(1, Math.round(comp.position.colSpan * ratio));
return {
...comp,
position: {
...comp.position,
col: newCol,
colSpan: Math.min(newColSpan, targetColumns),
},
};
});
// 2. 겹침 감지 및 해결
return resolveOverlaps(converted, targetColumns);
}
// 겹침 해결 (아래로 밀기)
function resolveOverlaps(
components: PopComponentDefinitionV5[],
columns: number
): PopComponentDefinitionV5[] {
// 행 단위로 그리드 점유 상태 추적
const grid: boolean[][] = [];
// row 순서대로 처리
const sorted = [...components].sort((a, b) =>
a.position.row - b.position.row || a.position.col - b.position.col
);
return sorted.map(comp => {
let { row, col, colSpan, rowSpan } = comp.position;
// 배치 가능한 위치 찾기
while (isOccupied(grid, row, col, colSpan, rowSpan, columns)) {
row++; // 아래로 이동
}
// 그리드에 표시
markOccupied(grid, row, col, colSpan, rowSpan);
return {
...comp,
position: { row, col, colSpan, rowSpan },
};
});
}
```
### 산출물
- `frontend/components/pop/designer/utils/gridLayoutUtils.ts`
---
## 마이그레이션 전략
### v4 → v5 변환
```typescript
function migrateV4ToV5(layoutV4: PopLayoutDataV4): PopLayoutDataV5 {
const componentsList = Object.values(layoutV4.components);
// Flexbox 순서 → Grid 위치 변환
let currentRow = 1;
let currentCol = 1;
const columns = 12;
const componentsV5: Record<string, PopComponentDefinitionV5> = {};
componentsList.forEach((comp, index) => {
// 기본 크기 추정 (픽셀 → 칸)
const colSpan = Math.max(1, Math.round((comp.size.fixedWidth || 100) / 85));
const rowSpan = Math.max(1, Math.round((comp.size.fixedHeight || 48) / 48));
// 줄바꿈 체크
if (currentCol + colSpan - 1 > columns) {
currentRow++;
currentCol = 1;
}
componentsV5[comp.id] = {
...comp,
position: {
col: currentCol,
row: currentRow,
colSpan,
rowSpan,
},
};
currentCol += colSpan;
});
return {
version: "pop-5.0",
gridConfig: { /* 기본값 */ },
components: componentsV5,
dataFlow: layoutV4.dataFlow,
settings: { /* 변환 */ },
};
}
```
### 하위 호환
| 버전 | 처리 방식 |
|------|----------|
| v1~v2 | v3로 변환 후 v5로 |
| v3 | v5로 직접 변환 |
| v4 | v5로 직접 변환 |
| v5 | 그대로 사용 |
---
## 일정 (예상)
| Phase | 작업 | 예상 기간 |
|-------|------|----------|
| 5.1 | 타입 정의 | 2~3일 |
| 5.2 | 그리드 렌더러 | 3~5일 |
| 5.3 | 디자이너 UI | 5~7일 |
| 5.4 | 반응형 자동화 | 3~5일 |
| - | 테스트 및 버그 수정 | 2~3일 |
| **총** | | **약 2~3주** |
---
## 리스크 및 대응
| 리스크 | 영향 | 대응 |
|--------|------|------|
| 기존 v4 화면 깨짐 | 높음 | 하위 호환 유지, v4 렌더러 보존 |
| 자동 변환 품질 | 중간 | 수동 오버라이드로 보완 |
| 드래그 UX 복잡 | 중간 | 스냅 기능으로 편의성 확보 |
| 성능 저하 | 낮음 | CSS Grid는 네이티브 성능 |
---
## 성공 기준
1. **배치 예측 가능**: "2열 3행"이라고 하면 정확히 그 위치에 표시
2. **일관된 디자인**: 12칸 → 4칸 전환 시 비율 유지
3. **쉬운 편집**: 드래그로 칸에 스냅되어 배치
4. **하위 호환**: 기존 v4 화면이 정상 동작
---
## 관련 문서
- [GRID_SYSTEM_DESIGN.md](./GRID_SYSTEM_DESIGN.md) - 그리드 시스템 설계 상세
- [PLAN.md](./PLAN.md) - 전체 POP 개발 계획
- [V4_UNIFIED_DESIGN_SPEC.md](./V4_UNIFIED_DESIGN_SPEC.md) - 현재 v4 스펙
---
*최종 업데이트: 2026-02-05*

View File

@ -1,83 +0,0 @@
# 기능별 색인
> **용도**: "이 기능 어디있어?", "비슷한 기능 찾아줘"
> **검색 팁**: Ctrl+F로 기능명, 키워드 검색
---
## 렌더링
| 기능 | 파일 | 함수/컴포넌트 | 설명 |
|------|------|--------------|------|
| 그리드 렌더링 | PopRenderer.tsx | `PopRenderer` | CSS Grid 기반 v5 렌더링 |
| 위치 변환 | gridUtils.ts | `convertPositionToMode()` | 12칸 → 4/6/8칸 변환 |
| 모드 감지 | pop-layout.ts | `detectGridMode()` | 뷰포트 너비로 모드 판별 |
| 컴포넌트 스타일 | PopRenderer.tsx | `convertPosition()` | 그리드 좌표 → CSS |
## 드래그 앤 드롭
| 기능 | 파일 | 함수/컴포넌트 | 설명 |
|------|------|--------------|------|
| 드롭 영역 | PopCanvas.tsx | `useDrop` | 캔버스에 컴포넌트 드롭 |
| 좌표 계산 | gridUtils.ts | `mouseToGridPosition()` | 마우스 → 그리드 좌표 |
| 빈 위치 찾기 | gridUtils.ts | `findNextEmptyPosition()` | 자동 배치 |
| DnD 타입 정의 | PopCanvas.tsx | `DND_ITEM_TYPES` | 인라인 정의 |
## 편집
| 기능 | 파일 | 함수/컴포넌트 | 설명 |
|------|------|--------------|------|
| 위치 편집 | ComponentEditorPanel.tsx | position 탭 | col, row 수정 |
| 크기 편집 | ComponentEditorPanel.tsx | position 탭 | colSpan, rowSpan 수정 |
| 라벨 편집 | ComponentEditorPanel.tsx | settings 탭 | 컴포넌트 라벨 |
| 표시/숨김 | ComponentEditorPanel.tsx | visibility 탭 | 모드별 표시 |
## 레이아웃 관리
| 기능 | 파일 | 함수/컴포넌트 | 설명 |
|------|------|--------------|------|
| 컴포넌트 추가 | pop-layout.ts | `addComponentToV5Layout()` | v5에 컴포넌트 추가 |
| 빈 레이아웃 | pop-layout.ts | `createEmptyPopLayoutV5()` | 초기 레이아웃 생성 |
| 타입 가드 | pop-layout.ts | `isV5Layout()` | v5 여부 확인 |
## 상태 관리
| 기능 | 파일 | 함수/컴포넌트 | 설명 |
|------|------|--------------|------|
| 레이아웃 상태 | PopDesigner.tsx | `useState<PopLayoutDataV5>` | 메인 레이아웃 |
| 히스토리 | PopDesigner.tsx | `history[]`, `historyIndex` | Undo/Redo |
| 선택 상태 | PopDesigner.tsx | `selectedComponentId` | 현재 선택 |
| 모드 상태 | PopDesigner.tsx | `currentMode` | 그리드 모드 |
## 저장/로드
| 기능 | 파일 | 함수/컴포넌트 | 설명 |
|------|------|--------------|------|
| 레이아웃 로드 | PopDesigner.tsx | `useEffect` | 화면 로드 시 |
| 레이아웃 저장 | PopDesigner.tsx | `handleSave()` | 저장 버튼 |
| API 호출 | screen.ts (lib/api) | `screenApi.saveLayoutPop()` | 백엔드 통신 |
## 뷰포트/줌
| 기능 | 파일 | 함수/컴포넌트 | 설명 |
|------|------|--------------|------|
| 프리셋 전환 | PopCanvas.tsx | `VIEWPORT_PRESETS` | 4개 모드 |
| 줌 컨트롤 | PopCanvas.tsx | `canvasScale` | 30%~150% |
| 패닝 | PopCanvas.tsx | Space + 드래그 | 캔버스 이동 |
---
## 파일별 주요 기능
| 파일 | 핵심 기능 |
|------|----------|
| PopDesigner.tsx | 레이아웃 로드/저장, 컴포넌트 CRUD, 히스토리 |
| PopCanvas.tsx | DnD, 줌, 패닝, 모드 전환, 그리드 표시 |
| PopRenderer.tsx | CSS Grid 렌더링, 위치 변환, 컴포넌트 표시 |
| ComponentEditorPanel.tsx | 속성 편집 (위치, 크기, 설정, 표시) |
| pop-layout.ts | 타입 정의, 유틸리티 함수, 상수 |
| gridUtils.ts | 좌표 계산, 겹침 감지, 자동 배치 |
---
*새 기능 추가 시 해당 카테고리 테이블에 행 추가*

View File

@ -48,20 +48,11 @@
## 작업 순서
```
[Phase 1~3] [Phase 4] [Phase 5]
v4 Flexbox → 실제 컴포넌트 → 그리드 시스템 (v5)
완료 다음 계획 승인
[Phase 1] [Phase 2] [Phase 3] [Phase 4]
v4 기본 구조 → 오버라이드 기능 → 컴포넌트 숨김 → 순서 오버라이드
완료 다음 계획 계획
```
### Phase 5: 그리드 시스템 (v5) - 신규 계획
```
[Phase 5.1] [Phase 5.2] [Phase 5.3] [Phase 5.4]
그리드 타입 정의 → 그리드 렌더러 → 디자이너 UI → 반응형 자동화
```
**상세 계획**: [GRID_SYSTEM_PLAN.md](./GRID_SYSTEM_PLAN.md)
---
## Phase 1: 기본 구조 (완료)
@ -95,96 +86,23 @@ v4 Flexbox → 실제 컴포넌트 → 그리드 시스템 (v5)
- [x] 디자인 모드 분리 (scale=1, 원본 유지)
- [x] DndProvider 에러 수정 (뷰어에서 useDrag/useDrop 방지)
## Phase 2: 오버라이드 기능 (완료) ✅
## Phase 2: 오버라이드 기능 (다음)
### Phase 2.1: 배치 고정 (완료)
- [x] 현재 모드 추적 (PopDesigner)
- [x] 고정 버튼 UI 및 로직
- [x] 오버라이드 저장 (배치만)
- [x] 오버라이드 초기화 로직
- [x] **버그 수정**: tempLayout 도입 (root 오염 방지)
- [x] **속성 패널**: 다른 모드에서 비활성화
- [ ] ModeOverride 데이터 구조 추가
- [ ] 편집 감지 → 자동 오버라이드 저장
- [ ] 편집 상태 표시 (버튼 색상 변경)
- [ ] "자동으로 되돌리기" 버튼
### Phase 2.2: 렌더러 오버라이드 적용 (완료) ✅
- [x] PopFlexRenderer에서 오버라이드 병합 (getMergedRoot)
- [x] 컨테이너 속성 오버라이드 적용 (direction, wrap, gap, alignItems, justifyContent, padding, children)
- [x] tempLayout 우선 표시 (고정 전 미리보기)
- [x] 테스트 (모드별 다른 배치)
## Phase 3: 컴포넌트 표시/숨김 (계획)
### Phase 2.3: 편집 자동 감지 (완료) ✅
- [x] 순서 변경 시 자동 tempLayout 저장
- [x] "고정" 버튼으로 정식 오버라이드 전환
- [x] 속성 변경은 기본 모드에서만 가능 (다른 모드 차단)
- [ ] visibility 속성 추가 (모드별 true/false)
- [ ] 속성 패널 체크박스 UI
- [ ] 렌더러에서 visibility 처리
## Phase 3: 컴포넌트 표시/숨김 + 줄바꿈 (완료) ✅
## Phase 4: 순서 오버라이드 (계획)
- [x] visibility 속성 추가 (모드별 true/false)
- [x] 속성 패널 "표시" 탭 추가 (체크박스 UI)
- [x] 렌더러에서 visibility 처리
- [x] pop-break 컴포넌트 추가 (강제 줄바꿈)
- [x] 컴포넌트 오버라이드 병합 로직
- [x] 삭제 시 오버라이드 정리 로직
## Phase 4: 실제 컴포넌트 구현 (다음)
- [ ] pop-field: 입력/표시 필드
- [ ] pop-button: 액션 버튼
- [ ] pop-list: 데이터 리스트 (카드 템플릿)
- [ ] pop-indicator: KPI/상태 표시
- [ ] pop-scanner: 바코드/QR 스캔
- [ ] pop-numpad: 숫자 입력 패드
---
## Phase 5: 그리드 시스템 (v5) - 계획 승인
> 상세 계획: [GRID_SYSTEM_PLAN.md](./GRID_SYSTEM_PLAN.md)
### 개요
Flexbox 흐름 기반 → **CSS Grid 위치 지정** 방식으로 전환
| 항목 | v4 (현재) | v5 (그리드) |
|------|----------|-------------|
| 배치 | Flexbox 흐름 | Grid 좌표 (열/행) |
| 크기 | 픽셀 (200px) | 칸 (colSpan, rowSpan) |
| 줄바꿈 | 자동 | 명시적 |
### Phase 5.1: 그리드 타입 정의
- [ ] `PopLayoutDataV5` 인터페이스
- [ ] `PopGridConfig` (칸 수, 행 높이, 간격)
- [ ] `PopComponentPositionV5` (col, row, colSpan, rowSpan)
- [ ] 브레이크포인트 상수 (4칸/6칸/8칸/12칸)
### Phase 5.2: 그리드 렌더러
- [ ] `PopGridRenderer.tsx` 생성
- [ ] CSS Grid 스타일 계산
- [ ] 브레이크포인트 감지 및 칸 수 변경
- [ ] 위치 변환 (12칸 → 4칸)
### Phase 5.3: 디자이너 UI
- [ ] `PopCanvasV5.tsx` (그리드 캔버스)
- [ ] 바둑판 배경 표시
- [ ] 드래그 스냅 (칸에 맞춤)
- [ ] 위치 편집 패널
### Phase 5.4: 반응형 자동화
- [ ] 자동 변환 알고리즘 (12칸 → 4칸)
- [ ] 겹침 감지 및 재배치
- [ ] 모드별 오버라이드
### 브레이크포인트
| 모드 | 화면 범위 | 그리드 칸 수 |
|------|----------|-------------|
| 모바일 세로 | ~599px (4~6인치) | 4칸 |
| 모바일 가로 | 600~839px (6~8인치) | 6칸 |
| 태블릿 세로 | 840~1023px (8~10인치) | 8칸 |
| 태블릿 가로 | 1024px~ (10~14인치) | 12칸 |
- [ ] 모드별 children 순서 오버라이드
- [ ] 드래그로 순서 변경 UI
---
@ -302,4 +220,4 @@ scaledSize = originalSize * scale
---
*최종 업데이트: 2026-02-05 (Phase 5 그리드 시스템 계획 추가)*
*최종 업데이트: 2026-02-04*

View File

@ -1,62 +0,0 @@
# 문제-해결 색인
> **용도**: "이전에 비슷한 문제 어떻게 해결했어?"
> **검색 팁**: Ctrl+F로 키워드 검색 (에러 메시지, 컴포넌트명 등)
---
## 렌더링 관련
| 문제 | 해결 | 날짜 | 키워드 |
|------|------|------|--------|
| rowSpan이 적용 안됨 | gridTemplateRows를 `1fr`로 변경 | 2026-02-02 | grid, rowSpan, CSS |
| 컴포넌트 크기 스케일 안됨 | viewportWidth 기반 scale 계산 추가 | 2026-02-04 | scale, viewport, 반응형 |
## DnD (드래그앤드롭) 관련
| 문제 | 해결 | 날짜 | 키워드 |
|------|------|------|--------|
| useDrag 에러 (뷰어에서) | isDesignMode 체크 후 early return | 2026-02-04 | DnD, useDrag, 뷰어 |
| DndProvider 중복 에러 | 최상위에서만 Provider 사용 | 2026-02-04 | DndProvider, react-dnd |
## 타입 관련
| 문제 | 해결 | 날짜 | 키워드 |
|------|------|------|--------|
| 인터페이스 이름 불일치 | V5 접미사 제거, 통일 | 2026-02-05 | 타입, interface, Props |
| v3/v4 타입 혼재 | v5 전용으로 통합, 레거시 삭제 | 2026-02-05 | 버전, 타입, 마이그레이션 |
## 레이아웃 관련
| 문제 | 해결 | 날짜 | 키워드 |
|------|------|------|--------|
| Flexbox 배치 예측 불가 | CSS Grid로 전환 (v5) | 2026-02-05 | Flexbox, Grid, 반응형 |
| 4모드 각각 배치 힘듦 | 제약조건 기반 시스템 (v4) | 2026-02-03 | 모드, 반응형, 제약조건 |
| 4모드 자동 전환 안됨 | useResponsiveMode 훅 추가 | 2026-02-01 | 모드, 훅, 반응형 |
## 저장/로드 관련
| 문제 | 해결 | 날짜 | 키워드 |
|------|------|------|--------|
| 레이아웃 버전 충돌 | isV5Layout 타입 가드로 분기 | 2026-02-05 | 버전, 로드, 타입가드 |
| 빈 레이아웃 판별 실패 | components 존재 여부로 판별 | 2026-02-04 | 빈 레이아웃, 로드 |
## UI/UX 관련
| 문제 | 해결 | 날짜 | 키워드 |
|------|------|------|--------|
| root 레이아웃 오염 | tempLayout 도입 (임시 상태 분리) | 2026-02-04 | tempLayout, 상태, 오염 |
| 속성 패널 다른 모드 수정 | isDefaultMode 체크로 비활성화 | 2026-02-04 | 속성패널, 모드, 비활성화 |
---
## 해결 안 된 문제 (진행 중)
| 문제 | 상태 | 관련 파일 |
|------|------|----------|
| PopCanvas 타입 오류 | 미해결 | PopCanvas.tsx:76 |
| 팔레트 UI 없음 | 미해결 | PopDesigner.tsx |
---
*새 문제-해결 추가 시 해당 카테고리 테이블에 행 추가*

View File

@ -1,74 +1,12 @@
# POP 화면 시스템
> **AI 에이전트 시작점**: 이 파일 → STATUS.md 순서로 읽으세요.
> 저장 요청 시: [SAVE_RULES.md](./SAVE_RULES.md) 참조
> Point of Production - 현장 작업자용 모바일/태블릿 화면
---
## 현재 상태
## Quick Reference
| 항목 | 값 |
|------|-----|
| 버전 | **v5** (CSS Grid 기반) |
| 상태 | **기본 기능 완료** |
| 다음 | 실제 테스트, Phase 4 (실제 컴포넌트 구현) |
**마지막 업데이트**: 2026-02-05
---
## 마지막 대화 요약
> (B)(C)(D) 모두 완료. 팔레트 UI 추가, 타입 오류 수정, 문서 v5 기준 통일.
> 다음: 실제 테스트 후 Phase 4 (실제 컴포넌트 렌더링, 데이터 바인딩) 진행.
---
## 빠른 경로
| 알고 싶은 것 | 문서 |
|--------------|------|
| 지금 뭐 해야 해? | [STATUS.md](./STATUS.md) |
| 저장/조회 규칙 | [SAVE_RULES.md](./SAVE_RULES.md) |
| 왜 v5로 바꿨어? | [decisions/003-v5-grid-system.md](./decisions/003-v5-grid-system.md) |
| 이전 문제 해결 | [PROBLEMS.md](./PROBLEMS.md) |
| 코드 어디 있어? | [FILES.md](./FILES.md) |
| 기능별 색인 | [INDEX.md](./INDEX.md) |
| 변경 히스토리 | [CHANGELOG.md](./CHANGELOG.md) |
---
## 핵심 파일
| 파일 | 역할 | 경로 |
|------|------|------|
| 타입 정의 | v5 레이아웃 타입 | `frontend/components/pop/designer/types/pop-layout.ts` |
| 캔버스 | 그리드 캔버스 + DnD | `frontend/components/pop/designer/PopCanvas.tsx` |
| 렌더러 | CSS Grid 렌더링 | `frontend/components/pop/designer/renderers/PopRenderer.tsx` |
| 디자이너 | 메인 컴포넌트 | `frontend/components/pop/designer/PopDesigner.tsx` |
---
## 문서 구조
```
[Layer 1: 먼저 읽기]
README.md (지금 여기) → STATUS.md
[Layer 2: 필요시 읽기]
CHANGELOG, PROBLEMS, INDEX, FILES, ARCHITECTURE, SPEC, PLAN
[Layer 3: 심화]
decisions/, sessions/, archive/
```
**컨텍스트 효율화**: 모든 문서를 읽지 마세요. 필요한 것만 단계적으로.
---
## POP이란?
**Point of Production** - 현장 작업자용 모바일/태블릿 화면 시스템
### 주요 경로
| 용도 | 경로 |
|------|------|
@ -76,19 +14,77 @@ decisions/, sessions/, archive/
| 관리 | `/admin/screenMng/popScreenMngList` |
| API | `/api/screen-management/layout-pop/:screenId` |
---
### 핵심 파일
## v5 그리드 시스템 (현재)
| 작업 | 파일 |
|------|------|
| 타입 | `frontend/components/pop/designer/types/pop-layout.ts` |
| 렌더러 | `frontend/components/pop/designer/renderers/` |
| 디자이너 | `frontend/components/pop/designer/PopDesigner.tsx` |
| 모드 | 화면 너비 | 칸 수 |
|------|----------|-------|
| mobile_portrait | ~599px | 4칸 |
| mobile_landscape | 600~839px | 6칸 |
| tablet_portrait | 840~1023px | 8칸 |
| tablet_landscape | 1024px~ | 12칸 |
### 현재 상태
**핵심**: 컴포넌트를 칸 단위로 배치 (col, row, colSpan, rowSpan)
- **버전**: v3.0 (4모드 그리드)
- **다음**: v4.0 (제약조건 기반) - 계획
---
*상세: [SPEC.md](./SPEC.md) | 히스토리: [CHANGELOG.md](./CHANGELOG.md)*
## 문서 구조
| 파일 | 용도 |
|------|------|
| [SPEC.md](./SPEC.md) | 기술 스펙 |
| [PLAN.md](./PLAN.md) | 계획/로드맵 |
| [CHANGELOG.md](./CHANGELOG.md) | 변경 이력 |
| [decisions/](./decisions/) | 중요 결정 기록 (ADR) |
| [components-spec.md](./components-spec.md) | 컴포넌트 상세 |
| [archive/](./archive/) | 이전 문서 |
---
## 저장/조회 시스템
### 역할 분담
| 저장소 | 역할 | 특징 |
|--------|------|------|
| **rangraph** | AI 장기 기억 | 시맨틱 검색, 요약 저장 |
| **popdocs** | 상세 기록 | 파일 기반, 히스토리 |
### 저장 요청
| 요청 예시 | AI 행동 |
|----------|--------|
| "@CHANGELOG.md 오늘 작업 정리해줘" | 파일 형식 맞춰 추가 + rangraph 요약 |
| "이 결정 저장해줘" | rangraph save_decision + decisions/ ADR |
| "해결됐어" | rangraph save_lesson + CHANGELOG Fixed |
| "작업 완료" | rangraph workflow_submit + CHANGELOG Added |
### 조회 요청
| 요청 예시 | AI 행동 |
|----------|--------|
| "어제 뭐했지?" | rangraph 검색 |
| "지금 뭐하는 중이었지?" | rangraph workflow_status |
| "이 버그 전에도 있었어?" | rangraph search_memory |
| "v4 관련 작업들" | rangraph search_memory "v4" |
---
## v4 핵심 (요약)
```
v3: 4개 모드 각각 위치 설정 → 4배 작업량
v4: 3가지 규칙만 설정 → 자동 적응
규칙:
1. 크기: fixed(고정) / fill(채움) / hug(맞춤)
2. 배치: direction, wrap, gap
3. 반응형: breakpoint별 변경
```
상세: [SPEC.md](./SPEC.md)
---
*최종 업데이트: 2026-02-04*

View File

@ -1,539 +0,0 @@
# popdocs 사용 규칙
> **AI 에이전트 필독**: 이 문서는 popdocs 폴더 사용법입니다.
> 사용자가 "@popdocs"와 함께 요청하면 이 규칙을 참조하세요.
---
## 요청 유형 인식
### 키워드로 요청 유형 판별
| 유형 | 키워드 예시 | 행동 |
|------|------------|------|
| **저장** | 저장해줘, 기록해줘, 정리해줘, 추가해줘 | → 저장 규칙 따르기 |
| **조회** | 찾아줘, 검색해줘, 뭐 있어?, 어디있어? | → 조회 규칙 따르기 |
| **분석** | 분석해줘, 비교해줘, 어떻게 달라? | → 분석 규칙 따르기 |
| **수정** | 수정해줘, 업데이트해줘, 고쳐줘 | → 수정 규칙 따르기 |
| **요약** | 요약해줘, 정리해서 보여줘, 보고서 | → 요약 규칙 따르기 |
| **작업시작** | 시작하자, 이어서 하자, 뭐 해야 해? | → 작업 시작 규칙 |
### 요청 유형별 행동
```
[저장 요청]
"@popdocs 오늘 작업 저장해줘"
→ SAVE_RULES.md 저장 섹션 → 적절한 파일에 저장 → 동기화
[조회 요청]
"@popdocs 이전에 DnD 문제 어떻게 해결했어?"
→ PROBLEMS.md 검색 → 관련 내용만 반환
[분석 요청]
"@popdocs v4랑 v5 뭐가 달라?"
→ decisions/ 또는 CHANGELOG 검색 → 비교표 생성
[수정 요청]
"@popdocs STATUS 업데이트해줘"
→ STATUS.md 수정 → README.md 동기화
[요약 요청]
"@popdocs 이번 주 작업 요약해줘"
→ sessions/ 해당 기간 검색 → 요약 생성
[작업 시작]
"@popdocs 오늘 작업 시작하자"
→ README → STATUS → 중단점 확인 → 작업 시작
```
---
## 컨텍스트 효율화 원칙
### Progressive Disclosure (점진적 공개)
**핵심**: 모든 문서를 한 번에 읽지 마세요. 필요한 것만 단계적으로.
```
Layer 1 (진입점) → README.md, STATUS.md (먼저 읽기, ~100줄)
Layer 2 (상세) → 필요한 문서만 선택적으로
Layer 3 (심화) → 코드 파일, archive/ (필요시만)
```
### Token as Currency (토큰은 자원)
| 원칙 | 설명 |
|------|------|
| **관련성 > 최신성** | 모든 히스토리 대신 관련 있는 것만 |
| **요약 > 전문** | 긴 내용 대신 요약 먼저 확인 |
| **링크 > 복사** | 내용 복사 대신 파일 경로 참조 |
| **테이블 > 산문** | 긴 설명 대신 표로 압축 |
| **검색 > 전체읽기** | Ctrl+F 키워드 검색 활용 |
### Context Bloat 방지
```
❌ 잘못된 방법:
"모든 문서를 읽고 파악한 후 작업하겠습니다"
→ 1,300줄 이상 낭비
✅ 올바른 방법:
"README → STATUS → 필요한 섹션만"
→ 평균 50~100줄로 작업 가능
```
---
## 문서 구조 (3계층)
```
popdocs/
├── [Layer 1: 진입점] ─────────────────────────
│ ├── README.md ← 시작점 (현재 상태 요약)
│ ├── STATUS.md ← 진행 상태, 다음 작업
│ └── SAVE_RULES.md ← 사용 규칙 (지금 읽는 문서)
├── [Layer 2: 상세 문서] ─────────────────────────
│ ├── CHANGELOG.md ← 변경 이력 (날짜별)
│ ├── PROBLEMS.md ← 문제-해결 색인
│ ├── INDEX.md ← 기능별 색인
│ ├── ARCHITECTURE.md ← 코드 구조
│ ├── FILES.md ← 파일 목록
│ ├── SPEC.md ← 기술 스펙
│ └── PLAN.md ← 계획
├── [Layer 3: 심화/기록] ─────────────────────────
│ ├── decisions/ ← ADR (결정 기록)
│ ├── sessions/ ← 날짜별 작업 기록
│ └── archive/ ← 보관 (레거시)
└── [외부 참조] ─────────────────────────
└── 실제 코드 → frontend/components/pop/designer/
```
---
## 조회 규칙 (읽기)
### 작업 시작 시
```
1. README.md 읽기 (~60줄)
└→ 현재 상태, 다음 작업 확인
2. STATUS.md 읽기 (~40줄)
└→ 상세 진행 상황, 중단점 확인
3. 필요한 문서만 선택적으로
```
### 요청별 조회 경로
| 사용자 요청 | 조회 경로 |
|-------------|----------|
| "지금 뭐 해야 해?" | README → STATUS |
| "어제 뭐 했어?" | sessions/어제날짜.md |
| "이전에 비슷한 문제?" | PROBLEMS.md (키워드 검색) |
| "이 기능 어디있어?" | INDEX.md 또는 FILES.md |
| "왜 이렇게 결정했어?" | decisions/ |
| "전체 히스토리" | CHANGELOG.md (기간 한정) |
| "코드 구조 알려줘" | ARCHITECTURE.md |
| "v4랑 v5 뭐가 달라?" | decisions/003 또는 CHANGELOG |
### 효율적 검색
```
# 전체 파일 읽지 말고 키워드 검색
PROBLEMS.md에서 "DnD" 검색 → 관련 행만
CHANGELOG.md에서 "2026-02-05" 검색 → 해당 날짜만
FILES.md에서 "렌더러" 검색 → 관련 파일만
```
---
## 저장 규칙 (쓰기)
### 저장 유형별 위치
| 요청 패턴 | 저장 위치 | 형식 |
|----------|----------|------|
| "오늘 작업 저장/정리해줘" | sessions/YYYY-MM-DD.md | 세션 템플릿 |
| "이 결정 기록해줘" | decisions/NNN-제목.md | ADR 템플릿 |
| "이 문제 해결 기록해줘" | PROBLEMS.md | 행 추가 |
| "작업 내용 추가해줘" | CHANGELOG.md | 섹션 추가 |
| "현재 상태 업데이트" | STATUS.md | 상태 수정 |
| "기능 색인 추가해줘" | INDEX.md | 행 추가 |
### 저장 후 필수 동기화
```
저장 완료 후 항상:
1. STATUS.md 업데이트 (진행 상태, 다음 작업)
2. README.md "마지막 대화 요약" 업데이트 (1-2줄)
```
---
## 분석/비교 규칙
### 비교 요청 시
```
사용자: "@popdocs v4랑 v5 뭐가 달라?"
AI 행동:
1. decisions/003-v5-grid-system.md 확인 (있으면)
2. 없으면 CHANGELOG에서 관련 날짜 검색
3. 비교표 형식으로 응답
응답 형식:
| 항목 | v4 | v5 |
|------|----|----|
| 배치 | Flexbox | CSS Grid |
| ... | ... | ... |
```
### 분석 요청 시
```
사용자: "@popdocs 이번 달 작업 분석해줘"
AI 행동:
1. sessions/ 폴더에서 해당 기간 파일 목록
2. 각 파일의 "요약" 섹션만 추출
3. 종합 분석 제공
```
---
## 수정 규칙
### 문서 수정 요청 시
```
사용자: "@popdocs STATUS 업데이트해줘"
AI 행동:
1. STATUS.md 읽기
2. 변경 내용 적용
3. README.md 동기화 (마지막 대화 요약)
4. 변경 내용 사용자에게 확인
```
### 여러 문서 수정 시
```
수정 순서:
상세 문서 먼저 → STATUS.md → README.md
(역방향: 진입점이 항상 최신 상태 유지)
```
---
## 요약/보고서 규칙
### 요약 요청 시
```
사용자: "@popdocs 이번 주 요약해줘"
AI 행동:
1. sessions/ 해당 기간 파일 확인
2. 각 파일의 "요약" + "완료" 섹션 추출
3. 압축된 형식으로 응답
응답 형식:
## 이번 주 요약 (02-01 ~ 02-05)
| 날짜 | 주요 작업 |
|------|----------|
| 02-05 | v5 통합 완료 |
| 02-04 | ... |
```
### 보고서 생성 요청 시
```
사용자: "@popdocs 진행 보고서 만들어줘"
AI 행동:
1. STATUS.md (현재 상태)
2. CHANGELOG.md (최근 작업)
3. sessions/ (상세 기록)
4. 보고서 형식으로 조합
→ 새 파일 생성하지 말고 응답으로 제공
→ 필요시 사용자가 저장 요청
```
---
## 예외 처리
### 분류 불가 시
```
사용자: "@popdocs 이거 저장해줘" (유형 불명확)
AI 행동:
→ 사용자에게 질문: "어떤 유형으로 저장할까요?"
- 오늘 작업 기록 (sessions/)
- 문제-해결 기록 (PROBLEMS.md)
- 결정 사항 (decisions/)
- 변경 이력 (CHANGELOG.md)
```
### 복합 요청 시
```
사용자: "@popdocs 문제 해결했고 결정도 내렸어. 저장해줘"
AI 행동 (순서):
1. PROBLEMS.md에 문제-해결 추가
2. decisions/에 ADR 생성 (필요시)
3. CHANGELOG.md에 섹션 추가
4. STATUS.md 업데이트
5. README.md 동기화
```
### 전체 조회 요청 시
```
사용자: "@popdocs 전체 히스토리 보여줘"
AI 행동:
→ "기간을 지정해주시면 더 정확히 찾아드릴게요."
- 예: "최근 1주일", "2월 작업", "v5 관련"
→ 기간 없이 강행 시: CHANGELOG.md 최근 5개 항목만
```
### 파일 없음 시
```
사용자: "@popdocs 어제 작업 보여줘" (sessions/어제.md 없음)
AI 행동:
→ "어제 작업 기록이 없습니다. CHANGELOG.md에서 찾아볼까요?"
```
### 키워드 검색 실패 시
```
사용자: "@popdocs DnD 문제 찾아줘" (PROBLEMS.md에 없음)
AI 행동:
→ "PROBLEMS.md에서 못 찾았습니다. 다른 곳도 검색할까요?"
- CHANGELOG.md
- INDEX.md
- sessions/
```
---
## 동기화 규칙
### 항상 동기화해야 하는 쌍
| 변경 문서 | 동기화 대상 |
|----------|-----------|
| sessions/ 생성 | STATUS.md (최근 세션) |
| PROBLEMS.md 추가 | - |
| decisions/ 생성 | STATUS.md (관련 결정), CHANGELOG.md |
| CHANGELOG.md 추가 | STATUS.md (진행 상태) |
| STATUS.md 수정 | README.md (마지막 요약) |
### 불일치 발견 시
```
README.md와 STATUS.md 내용이 다르면:
→ STATUS.md를 정본(正本)으로
→ README.md를 STATUS.md 기준으로 업데이트
```
---
## 정리 규칙
### 주기적 정리 (수동 요청 시)
| 대상 | 조건 | 조치 |
|------|------|------|
| sessions/ | 30일 이상 | archive/sessions/로 이동 |
| PROBLEMS.md | 100행 초과 | 카테고리별 분리 검토 |
| CHANGELOG.md | 연도 변경 | 이전 연도 archive/로 |
### 정리 요청 패턴
```
사용자: "@popdocs 오래된 파일 정리해줘"
AI 행동:
1. sessions/ 30일 이상 파일 목록 제시
2. 사용자 확인 후 archive/로 이동
3. 강제 삭제하지 않음
```
---
## 템플릿
### 세션 기록 (sessions/YYYY-MM-DD.md)
```markdown
# YYYY-MM-DD 작업 기록
## 요약
(한 줄 요약 - 50자 이내)
## 완료
- [x] 작업1
- [x] 작업2
## 미완료
- [ ] 작업3 (이유: ...)
## 중단점
> (내일 이어서 할 때 바로 시작할 수 있는 정보)
## 대화 핵심
- 키워드1: 설명
- 키워드2: 설명
## 관련 링크
- CHANGELOG: #YYYY-MM-DD
- ADR: decisions/NNN (있으면)
```
### 문제-해결 (PROBLEMS.md 행 추가)
```markdown
| 문제 | 해결 | 날짜 | 키워드 |
|------|------|------|--------|
| (에러/문제 설명) | (해결 방법) | YYYY-MM-DD | 검색용 |
```
### ADR (decisions/NNN-제목.md)
```markdown
# ADR-NNN: 제목
**날짜**: YYYY-MM-DD
**상태**: 채택됨
## 배경 (왜)
(2-3문장)
## 결정 (무엇)
(핵심 결정 사항)
## 대안
| 옵션 | 장점 | 단점 | 결과 |
|------|------|------|------|
## 교훈
- (배운 점)
```
### CHANGELOG 섹션
```markdown
## [YYYY-MM-DD] 제목
### 배경
(왜 - 2-3문장)
### 작업
- [x] 완료1
- [ ] 미완료 → STATUS.md로
### 다음
→ STATUS.md 참조
```
---
## 토큰 비용 가이드
| 문서 | 평균 줄 수 | 언제 읽나 |
|------|-----------|----------|
| README.md | ~60 | 항상 (진입점) |
| STATUS.md | ~40 | 항상 (진입점) |
| SAVE_RULES.md | ~350 | 저장/조회 요청 시 |
| sessions/날짜.md | ~30 | 해당 날짜만 |
| PROBLEMS.md | ~100 | 키워드 검색 |
| INDEX.md | ~80 | 키워드 검색 |
| CHANGELOG.md | ~600 | 날짜/키워드 검색 |
| ARCHITECTURE.md | ~300 | 구조 파악 시 |
| FILES.md | ~200 | 파일 찾을 때 |
**권장**: README + STATUS = ~100줄로 작업 시작
---
## 예시 워크플로우
### 1. 작업 시작
```
사용자: "@popdocs 오늘 작업 시작하자"
AI:
1. README.md 읽기 (60줄)
2. STATUS.md 읽기 (40줄)
3. 중단점 확인
4. "중단점: PopCanvas.tsx line 76 타입 오류. 여기서 시작할까요?"
```
### 2. 자료 검색
```
사용자: "@popdocs DnD 관련 자료 찾아줘"
AI:
1. PROBLEMS.md에서 "DnD" 검색
2. INDEX.md에서 "DnD" 검색
3. 관련 내용 종합해서 응답
4. 전체 파일 읽지 않음
```
### 3. 비교 요청
```
사용자: "@popdocs v4랑 v5 비교해줘"
AI:
1. decisions/003-v5-grid-system.md 확인
2. "대안 검토" 섹션에서 비교표 추출
3. 필요시 CHANGELOG 보충
```
### 4. 작업 저장
```
사용자: "@popdocs 오늘 작업 저장해줘"
AI:
1. sessions/2026-02-05.md 생성
2. STATUS.md 업데이트
3. README.md "마지막 대화 요약" 업데이트
4. "저장 완료. 내일 이어서 할 중단점도 기록했습니다."
```
### 5. 불명확한 요청
```
사용자: "@popdocs 이거 저장해줘"
AI:
"어떤 유형으로 저장할까요?
1. 오늘 작업 기록 (sessions/)
2. 문제-해결 (PROBLEMS.md)
3. 결정 사항 (decisions/)
4. 변경 이력 (CHANGELOG.md)"
```
---
*2025-2026 AI 컨텍스트 엔지니어링 기반*
*Progressive Disclosure, Agentic Context Engineering, Token as Currency*

View File

@ -1,122 +1,43 @@
# POP 기술 스펙
**버전: v5 (CSS Grid 기반)**
---
## v5 핵심 규칙
## v4 핵심 규칙 (3가지)
### 1. 그리드 시스템
### 1. 크기 규칙 (Size)
| 모드 | 화면 너비 | 칸 수 | 대상 |
|------|----------|-------|------|
| mobile_portrait | ~599px | 4칸 | 4~6인치 |
| mobile_landscape | 600~839px | 6칸 | 7인치 |
| tablet_portrait | 840~1023px | 8칸 | 8~10인치 |
| tablet_landscape | 1024px~ | 12칸 | 10~14인치 (기본) |
| 모드 | 설명 | 예시 |
|------|------|------|
| `fixed` | 고정 px | 버튼 48px |
| `fill` | 부모 채움 | 입력창 100% |
| `hug` | 내용 맞춤 | 라벨 |
### 2. 위치 지정
### 2. 배치 규칙 (Layout)
| 항목 | 옵션 |
|------|------|
| direction | horizontal / vertical |
| wrap | true / false |
| gap | 8 / 16 / 24 px |
| alignItems | start / center / end / stretch |
### 3. 반응형 규칙 (Responsive)
```typescript
interface PopGridPosition {
col: number; // 시작 열 (1부터)
row: number; // 시작 행 (1부터)
colSpan: number; // 열 크기 (1~12)
rowSpan: number; // 행 크기 (1~)
{
direction: "horizontal",
responsive: [{ breakpoint: 768, direction: "vertical" }]
}
```
### 3. 브레이크포인트 설정
```typescript
const GRID_BREAKPOINTS = {
mobile_portrait: {
columns: 4,
rowHeight: 48,
gap: 8,
padding: 12
},
mobile_landscape: {
columns: 6,
rowHeight: 44,
gap: 8,
padding: 16
},
tablet_portrait: {
columns: 8,
rowHeight: 52,
gap: 12,
padding: 20
},
tablet_landscape: {
columns: 12,
rowHeight: 56,
gap: 12,
padding: 24
},
};
```
---
## 데이터 구조
### v5 레이아웃
```typescript
interface PopLayoutDataV5 {
version: "pop-5.0";
metadata: {
screenId: number;
createdAt: string;
updatedAt: string;
};
gridConfig: {
defaultMode: GridMode;
maxRows: number;
};
components: PopComponentDefinitionV5[];
globalSettings: {
backgroundColor: string;
padding: number;
};
}
```
### v5 컴포넌트
```typescript
interface PopComponentDefinitionV5 {
id: string;
type: PopComponentType; // "pop-label" | "pop-button" | ...
label: string;
gridPosition: PopGridPosition;
config: PopComponentConfig;
visibility: Record<GridMode, boolean>; // 모드별 표시/숨김
modeOverrides?: Record<GridMode, PopModeOverrideV5>; // 모드별 오버라이드
}
```
### 컴포넌트 타입
```typescript
type PopComponentType =
| "pop-label" // 텍스트 라벨
| "pop-button" // 버튼
| "pop-input" // 입력 필드
| "pop-select" // 선택 박스
| "pop-grid" // 데이터 그리드
| "pop-container"; // 컨테이너
```
---
## 크기 프리셋
### 터치 요소
| 요소 | 일반 | 산업 |
|------|-----|-------|
| 요소 | 일반 | 산업 |
|------|-----|------|
| 버튼 높이 | 48px | 60px |
| 입력창 높이 | 48px | 56px |
| 터치 영역 | 48px | 60px |
@ -141,64 +62,60 @@ type PopComponentType =
## 반응형 원칙
```
누르는 것 → 고정 (48px) - 버튼, 터치 영역
읽는 것 → 범위 (clamp) - 텍스트
담는 것 → 칸 (colSpan) - 컨테이너
누르는 것 → 고정 (48px)
읽는 것 → 범위 (clamp)
담는 것 → 비율 (%)
```
---
## 위치 변환
## 데이터 구조
12칸 기준으로 설계 → 다른 모드에서 자동 변환
### v3 (현재)
```typescript
// 12칸 → 4칸 변환 예시
const ratio = 4 / 12; // = 0.333
interface PopLayoutDataV3 {
version: "pop-3.0";
layouts: {
tablet_landscape: { componentPositions: Record<string, GridPosition> };
// ... 4개 모드
};
components: Record<string, PopComponentDefinition>;
}
```
original: { col: 1, colSpan: 6 } // 12칸에서 절반
converted: { col: 1, colSpan: 2 } // 4칸에서 절반
### v4 (계획)
```typescript
interface PopLayoutDataV4 {
version: "pop-4.0";
root: PopContainer;
components: Record<string, PopComponentDefinitionV4>;
}
interface PopContainer {
type: "stack";
direction: "horizontal" | "vertical";
children: (string | PopContainer)[];
responsive?: { breakpoint: number; direction?: string }[];
}
```
---
## Troubleshooting
### 컴포넌트가 얇게 보임
### 캔버스 rowSpan 문제
- **증상**: rowSpan이 적용 안됨
- **증상**: 컴포넌트가 얇게 보임
- **원인**: gridTemplateRows 고정 px
- **해결**: `1fr` 사용
### 모드 전환 안 됨
### 4모드 전환 안 됨
- **증상**: 화면 크기 변경해도 레이아웃 유지
- **해결**: `detectGridMode()` 사용
### 겹침 발생
- **증상**: 컴포넌트끼리 겹침
- **해결**: `resolveOverlaps()` 호출
- **증상**: 크기 줄여도 레이아웃 유지
- **해결**: useResponsiveMode 훅 사용
---
## 타입 가드
```typescript
// v5 레이아웃 판별
function isV5Layout(data: any): data is PopLayoutDataV5 {
return data?.version === "pop-5.0";
}
// 사용 예시
if (isV5Layout(savedData)) {
setLayout(savedData);
} else {
setLayout(createEmptyPopLayoutV5());
}
```
---
*상세 아키텍처: [ARCHITECTURE.md](./ARCHITECTURE.md)*
*파일 목록: [FILES.md](./FILES.md)*
*상세 archive 참조: `archive/V4_CORE_RULES.md`, `archive/SIZE_PRESETS.md`*

View File

@ -1,68 +0,0 @@
# 현재 상태
> **마지막 업데이트**: 2026-02-05
> **담당**: POP 화면 디자이너
---
## 진행 상태
| 단계 | 상태 | 설명 |
|------|------|------|
| v5 타입 정의 | 완료 | `pop-layout.ts` |
| v5 렌더러 | 완료 | `PopRenderer.tsx` |
| v5 캔버스 | 완료 | `PopCanvas.tsx` |
| v5 편집 패널 | 완료 | `ComponentEditorPanel.tsx` |
| v5 유틸리티 | 완료 | `gridUtils.ts` |
| 레거시 삭제 | 완료 | v1~v4 코드, 데이터 |
| 문서 정리 | **완료** | popdocs v5 기준 재정비 |
| 컴포넌트 팔레트 | **완료** | `ComponentPalette.tsx` |
| 타입 오류 수정 | **완료** | PopCanvas.tsx:76 |
| 드래그앤드롭 | **완료** | 팔레트 → 캔버스 연결 |
---
## 다음 작업 (우선순위)
1. **실제 테스트**
- 디자이너 페이지에서 컴포넌트 드래그앤드롭 테스트
- 저장/로드 동작 확인
2. **실제 컴포넌트 구현** (Phase 4)
- pop-label, pop-button 등 실제 렌더링
- 데이터 바인딩 연결
3. **추가 기능**
- 컴포넌트 복사/붙여넣기
- 다중 선택
- 정렬 도우미
---
## 알려진 문제
| 문제 | 상태 | 비고 |
|------|------|------|
| 타입 이름 불일치 | 해결됨 | V5 접미사 제거 |
| 팔레트 없음 | 해결됨 | ComponentPalette.tsx 추가 |
---
## 최근 세션
| 날짜 | 요약 | 상세 |
|------|------|------|
| 2026-02-05 | v5 통합, 문서 재정비, 팔레트 UI 추가 | [sessions/2026-02-05.md](./sessions/2026-02-05.md) |
---
## 관련 결정
| ADR | 제목 | 날짜 |
|-----|------|------|
| 003 | v5 CSS Grid 채택 | 2026-02-05 |
| 001 | v4 제약조건 기반 | 2026-02-03 |
---
*전체 히스토리: [CHANGELOG.md](./CHANGELOG.md)*

View File

@ -1,8 +1,7 @@
# POP v4 통합 설계 모드 스펙
**작성일: 2026-02-04**
**최종 업데이트: 2026-02-04**
**상태: Phase 3 완료 (visibility + 줄바꿈 컴포넌트)**
**상태: Phase 1.6 완료 (비율 스케일링 시스템)**
---
@ -53,12 +52,12 @@ v3/v4 탭을 제거하고, **v4 자동 모드를 기본**으로 하되 **모드
│ │ [태블릿↕][태블릿↔(기본)] │ │
│ 필드 │ 너비: [====●====] 1024 x 768 │ │
│ 버튼 │ │ │
│ 리스트 │ ┌──────────────────────────────┐ │ 탭: 크기
│ 인디케이터 │ │ [필드1] [필드2] [필드3] │ │ 설정
│ 스캐너 │ │ [필드4] [Spacer] [버튼] │ │ 표시 ⬅ 🆕
│ 숫자패드 │ │ │ │ 데이터
│ 리스트 │ ┌──────────────────────────────┐ │
│ 인디케이터 │ │ [필드1] [필드2] [필드3] │ │
│ 스캐너 │ │ [필드4] [Spacer] [버튼] │ │
│ 숫자패드 │ │ │ │
│ 스페이서 │ │ (가로 배치 + 자동 줄바꿈) │ │ │
줄바꿈 🆕 │ │ (스크롤 가능) │ │ │
│ │ (스크롤 가능) │ │ │
│ │ └──────────────────────────────┘ │ │
│ │ 태블릿 가로 (1024x768) │ │
└────────────┴────────────────────────────────────┴───────────────┘
@ -74,24 +73,14 @@ v3/v4 탭을 제거하고, **v4 자동 모드를 기본**으로 하되 **모드
| Adalo 2.0 | Flexbox + Constraints |
| **POP v4** | **Flexbox (horizontal + wrap)** |
### 특수 컴포넌트 사용법
### Spacer 컴포넌트 사용법
#### Spacer (빈 공간)
```
[버튼A] [Spacer(fill)] [버튼B] → 버튼B가 오른쪽 끝으로
[Spacer] [컴포넌트] [Spacer] → 컴포넌트가 가운데로
[Spacer(fill)] [컴포넌트] → 컴포넌트가 오른쪽으로
```
#### 줄바꿈 (Break) 🆕 Phase 3
```
[필드A] [필드B] [줄바꿈] [필드C] → 필드C가 새 줄로 이동
태블릿: [필드A] [필드B] [필드C] ← 줄바꿈 숨김 (한 줄)
모바일: [필드A] [필드B] ← 줄바꿈 표시 (두 줄)
[필드C]
```
### 프리셋 버튼 (4개 모드)
| 버튼 | 해상도 | 설명 |
@ -307,85 +296,5 @@ scaledPadding = originalPadding * scale
---
## Phase 3: Visibility + 줄바꿈 컴포넌트 (완료) ✅
### 개요
모드별 컴포넌트 표시/숨김 제어 및 강제 줄바꿈 기능 추가.
### 추가 타입
#### visibility 속성
```typescript
interface PopComponentDefinitionV4 {
// 기존 속성...
// 🆕 모드별 표시/숨김
visibility?: {
tablet_landscape?: boolean;
tablet_portrait?: boolean;
mobile_landscape?: boolean;
mobile_portrait?: boolean;
};
}
```
#### pop-break 컴포넌트
```typescript
type PopComponentType =
| "pop-field"
| "pop-button"
| "pop-list"
| "pop-indicator"
| "pop-scanner"
| "pop-numpad"
| "pop-spacer"
| "pop-break"; // 🆕 줄바꿈
```
### 사용 예시
#### 모바일 전용 버튼
```typescript
{
id: "call-button",
type: "pop-button",
label: "전화 걸기",
visibility: {
tablet_landscape: false, // 태블릿: 숨김
mobile_portrait: true, // 모바일: 표시
},
}
```
#### 모드별 줄바꿈
```
레이아웃: [A] [B] [줄바꿈] [C] [D]
줄바꿈 visibility: { tablet_landscape: false, mobile_portrait: true }
결과:
태블릿: [A] [B] [C] [D] (한 줄)
모바일: [A] [B] (두 줄)
[C] [D]
```
### 속성 패널 "표시" 탭
```
┌─────────────────────┐
│ 탭: 크기 설정 표시 📍│
├─────────────────────┤
│ 모드별 표시 설정 │
│ ☑ 태블릿 가로 │
│ ☑ 태블릿 세로 │
│ ☐ 모바일 가로 (숨김)│
│ ☑ 모바일 세로 │
└─────────────────────┘
```
### 참고 문서
- [decisions/002-phase3-visibility-break.md](./decisions/002-phase3-visibility-break.md) - 상세 설계
---
*이 문서는 v4 통합 설계 모드의 스펙을 정의합니다.*
*최종 업데이트: 2026-02-04 (Phase 3 완료)*
*최종 업데이트: 2026-02-04*

View File

@ -1,518 +0,0 @@
# Phase 3 완료 요약
**날짜**: 2026-02-04
**상태**: 완료 ✅
**버전**: v4.0 Phase 3
---
## 🎯 달성 목표
Phase 2의 배치 고정 기능 이후, 다음 3가지 핵심 기능 추가:
1. ✅ **모드별 컴포넌트 표시/숨김** (visibility)
2. ✅ **강제 줄바꿈 컴포넌트** (pop-break)
3. ✅ **컴포넌트 오버라이드 병합** (모드별 설정 변경)
---
## 📦 구현 내용
### 1. 타입 정의
**파일**: `frontend/components/pop/designer/types/pop-layout.ts`
```typescript
// pop-break 추가
export type PopComponentType =
| "pop-field"
| "pop-button"
| "pop-list"
| "pop-indicator"
| "pop-scanner"
| "pop-numpad"
| "pop-spacer"
| "pop-break"; // 🆕
// visibility 속성 추가
export interface PopComponentDefinitionV4 {
id: string;
type: PopComponentType;
size: PopSizeConstraintV4;
visibility?: {
tablet_landscape?: boolean;
tablet_portrait?: boolean;
mobile_landscape?: boolean;
mobile_portrait?: boolean;
};
// ...
}
// 기본 크기
defaultSizes["pop-break"] = {
width: "fill", // 100% 너비
height: "fixed",
fixedHeight: 0, // 높이 0
};
```
---
### 2. 렌더러 로직
**파일**: `frontend/components/pop/designer/renderers/PopFlexRenderer.tsx`
#### visibility 체크
```typescript
const isComponentVisible = (component: PopComponentDefinitionV4): boolean => {
if (!component.visibility) return true; // 기본값: 표시
const modeVisibility = component.visibility[currentMode];
return modeVisibility !== false; // undefined도 true로 취급
};
```
#### 컴포넌트 오버라이드 병합
```typescript
const getMergedComponent = (baseComponent: PopComponentDefinitionV4) => {
if (currentMode === "tablet_landscape") return baseComponent;
const override = overrides?.[currentMode]?.components?.[baseComponent.id];
if (!override) return baseComponent;
// 깊은 병합 (config, size)
return {
...baseComponent,
...override,
size: { ...baseComponent.size, ...override.size },
config: { ...baseComponent.config, ...override.config },
};
};
```
#### pop-break 렌더링
```typescript
if (mergedComponent.type === "pop-break") {
return (
<div
style={{ flexBasis: "100%" }} // 핵심: 100% 너비로 줄바꿈 강제
className={isDesignMode
? "h-4 border-2 border-dashed border-gray-300"
: "h-0"
}
>
{isDesignMode && <span>줄바꿈</span>}
</div>
);
}
```
---
### 3. 삭제 함수 개선
**파일**: `frontend/components/pop/designer/types/pop-layout.ts`
```typescript
export const removeComponentFromV4Layout = (
layout: PopLayoutDataV4,
componentId: string
): PopLayoutDataV4 => {
// 1. components에서 삭제
const { [componentId]: _, ...remainingComponents } = layout.components;
// 2. root.children에서 제거
const newRoot = removeChildFromContainer(layout.root, componentId);
// 3. 🆕 모든 오버라이드에서 제거
const newOverrides = cleanupOverridesAfterDelete(layout.overrides, componentId);
return {
...layout,
root: newRoot,
components: remainingComponents,
overrides: newOverrides,
};
};
```
#### 오버라이드 정리 로직
```typescript
function cleanupOverridesAfterDelete(
overrides: PopLayoutDataV4["overrides"],
componentId: string
) {
// 각 모드별로:
// 1. containers.root.children에서 componentId 제거
// 2. components[componentId] 제거
// 3. 빈 오버라이드 자동 삭제
}
```
---
### 4. 속성 패널 UI
**파일**: `frontend/components/pop/designer/panels/ComponentEditorPanelV4.tsx`
#### "표시" 탭 추가
```typescript
<TabsList>
<TabsTrigger value="size">크기</TabsTrigger>
<TabsTrigger value="settings">설정</TabsTrigger>
<TabsTrigger value="visibility">
<Eye className="h-3 w-3" />
표시
</TabsTrigger>
<TabsTrigger value="data">데이터</TabsTrigger>
</TabsList>
```
#### VisibilityForm 컴포넌트
```typescript
function VisibilityForm({ component, onUpdate }) {
const modes = [
{ key: "tablet_landscape", label: "태블릿 가로" },
{ key: "tablet_portrait", label: "태블릿 세로" },
{ key: "mobile_landscape", label: "모바일 가로" },
{ key: "mobile_portrait", label: "모바일 세로" },
];
return (
<div>
{modes.map(({ key, label }) => (
<input
type="checkbox"
checked={component.visibility?.[key] !== false}
onChange={(e) => {
onUpdate?.({
visibility: {
...component.visibility,
[key]: e.target.checked,
},
});
}}
/>
))}
</div>
);
}
```
---
### 5. 팔레트 업데이트
**파일**: `frontend/components/pop/designer/panels/ComponentPaletteV4.tsx`
```typescript
const COMPONENT_PALETTE = [
// ... 기존 컴포넌트들
{
type: "pop-break",
label: "줄바꿈",
icon: WrapText,
description: "강제 줄바꿈 (flex-basis: 100%)",
},
];
```
---
## 🎨 UI 변경사항
### 컴포넌트 팔레트
```
컴포넌트
├─ 필드
├─ 버튼
├─ 리스트
├─ 인디케이터
├─ 스캐너
├─ 숫자패드
├─ 스페이서
└─ 줄바꿈 🆕
```
### 속성 패널
```
┌─────────────────────┐
│ 탭: [크기][설정] │
│ [표시📍][데이터] │
├─────────────────────┤
│ 모드별 표시 설정 │
│ ☑ 태블릿 가로 │
│ ☑ 태블릿 세로 │
│ ☐ 모바일 가로 (숨김)│
│ ☑ 모바일 세로 │
├─────────────────────┤
│ 반응형 숨김 │
│ [500] px 이하 숨김 │
└─────────────────────┘
```
---
## 📖 사용 예시
### 예시 1: 모바일 전용 버튼
```typescript
{
id: "call-button",
type: "pop-button",
label: "전화 걸기",
visibility: {
tablet_landscape: false, // 태블릿: 숨김
tablet_portrait: false,
mobile_landscape: true, // 모바일: 표시
mobile_portrait: true,
},
}
```
**결과**:
- 태블릿 화면: "전화 걸기" 버튼 안 보임
- 모바일 화면: "전화 걸기" 버튼 보임
---
### 예시 2: 모드별 줄바꿈
```typescript
레이아웃: [A] [B] [줄바꿈] [C] [D]
줄바꿈 설정:
{
id: "break-1",
type: "pop-break",
visibility: {
tablet_landscape: false, // 태블릿: 줄바꿈 숨김
mobile_portrait: true, // 모바일: 줄바꿈 표시
}
}
```
**결과**:
```
태블릿 가로 (1024px):
┌───────────────────────────┐
│ [A] [B] [C] [D] │ ← 한 줄
└───────────────────────────┘
모바일 세로 (375px):
┌─────────────────┐
│ [A] [B] │ ← 첫 줄
│ [C] [D] │ ← 둘째 줄 (줄바꿈 적용)
└─────────────────┘
```
---
### 예시 3: 리스트 컬럼 수 변경 (확장 가능)
```typescript
// 기본 (태블릿 가로)
{
id: "product-list",
type: "pop-list",
config: {
columns: 7, // 7개 컬럼
}
}
// 오버라이드 (모바일 세로)
overrides: {
mobile_portrait: {
components: {
"product-list": {
config: {
columns: 3, // 3개 컬럼
}
}
}
}
}
```
**결과**:
- 태블릿: 7개 컬럼 표시
- 모바일: 3개 컬럼 표시 (자동 병합)
---
## 🧪 테스트 시나리오
### ✅ 테스트 1: 줄바꿈 기본 동작
1. 팔레트에서 "줄바꿈" 드래그
2. 컴포넌트 사이에 드롭
3. 디자인 모드에서 점선 "줄바꿈" 표시 확인
4. 미리보기에서 줄바꿈이 안 보이는지 확인
### ✅ 테스트 2: 모드별 줄바꿈
1. 줄바꿈 컴포넌트 추가
2. "표시" 탭 → 태블릿 모드 체크 해제
3. 태블릿 가로: 한 줄
4. 모바일 세로: 두 줄
### ✅ 테스트 3: 삭제 시 오버라이드 정리
1. 모바일 세로에서 배치 고정
2. 컴포넌트 삭제
3. 저장 후 로드
4. DB 확인: overrides에서도 제거되었는지
### ✅ 테스트 4: 컴포넌트 숨김
1. 컴포넌트 선택
2. "표시" 탭 → 태블릿 모드 체크 해제
3. 태블릿: 컴포넌트 안 보임
4. 모바일: 컴포넌트 보임
### ✅ 테스트 5: 속성 패널 UI
1. 컴포넌트 선택
2. "표시" 탭 클릭
3. 4개 체크박스 확인
4. 체크 해제 시 "(숨김)" 표시
5. 저장 후 로드 → 상태 유지
---
## 📝 수정된 파일
### 코드 파일 (5개)
```
✅ frontend/components/pop/designer/types/pop-layout.ts
- PopComponentType 확장 (pop-break)
- PopComponentDefinitionV4.visibility 추가
- cleanupOverridesAfterDelete() 추가
✅ frontend/components/pop/designer/renderers/PopFlexRenderer.tsx
- isComponentVisible() 추가
- getMergedComponent() 추가
- pop-break 렌더링 추가
- ContainerRenderer props 확장
✅ frontend/components/pop/designer/panels/ComponentEditorPanelV4.tsx
- "표시" 탭 추가
- VisibilityForm 컴포넌트 추가
- COMPONENT_TYPE_LABELS 업데이트
✅ frontend/components/pop/designer/panels/ComponentPaletteV4.tsx
- "줄바꿈" 컴포넌트 추가
✅ frontend/components/pop/designer/PopDesigner.tsx
- (기존 Phase 2 변경사항 유지)
```
### 문서 파일 (6개)
```
✅ popdocs/CHANGELOG.md
- Phase 3 완료 기록
✅ popdocs/PLAN.md
- Phase 3 체크 완료
- Phase 4 계획 추가
✅ popdocs/V4_UNIFIED_DESIGN_SPEC.md
- Phase 3 섹션 추가
✅ popdocs/components-spec.md
- pop-break 상세 스펙 추가
- Phase 3 업데이트 노트
✅ popdocs/README.md
- 현재 상태 업데이트
- Phase 3 요약 추가
✅ popdocs/decisions/002-phase3-visibility-break.md (신규)
- 상세 설계 문서
✅ popdocs/PHASE3_SUMMARY.md (신규)
- 이 문서
```
---
## 🎓 핵심 개념
### Flexbox 줄바꿈 원리
```css
/* 컨테이너 */
.container {
display: flex;
flex-direction: row;
flex-wrap: wrap; /* 필수 */
}
/* pop-break */
.pop-break {
flex-basis: 100%; /* 전체 너비 차지 → 다음 요소는 새 줄로 */
height: 0; /* 실제로는 안 보임 */
}
```
### visibility vs hideBelow
| 속성 | 제어 방식 | 용도 |
|------|----------|------|
| `visibility` | 모드별 명시적 | 특정 모드에서만 표시 (예: 모바일 전용) |
| `hideBelow` | 픽셀 기반 자동 | 화면 너비에 따라 자동 숨김 (예: 500px 이하) |
**예시**:
```typescript
{
visibility: {
tablet_landscape: false, // 태블릿 가로: 무조건 숨김
},
hideBelow: 500, // 500px 이하: 자동 숨김 (다른 모드에서도)
}
```
---
## 🚀 다음 단계
### Phase 4: 실제 컴포넌트 구현
```
우선순위:
1. pop-field (입력/표시 필드)
2. pop-button (액션 버튼)
3. pop-list (데이터 리스트)
4. pop-indicator (KPI 표시)
5. pop-scanner (바코드/QR)
6. pop-numpad (숫자 입력)
```
### 추가 개선 사항
```
1. 컴포넌트 오버라이드 UI
- 리스트 컬럼 수 조정 UI
- 버튼 스타일 변경 UI
- 필드 표시 형식 변경 UI
2. "모든 모드에 적용" 기능
- 한 번에 모든 모드 체크/해제
3. 오버라이드 비교 뷰
- 기본값 vs 오버라이드 차이 시각화
```
---
## ✨ 주요 성과
1. ✅ **모드별 컴포넌트 제어**: visibility 속성으로 유연한 표시/숨김
2. ✅ **Flexbox 줄바꿈 해결**: pop-break 컴포넌트로 업계 표준 달성
3. ✅ **확장 가능한 구조**: 컴포넌트 오버라이드 병합으로 추후 기능 추가 용이
4. ✅ **데이터 일관성**: 삭제 시 오버라이드 자동 정리로 데이터 무결성 유지
5. ✅ **직관적인 UI**: 체크박스 기반 visibility 제어
---
## 📚 참고 문서
- [decisions/002-phase3-visibility-break.md](./decisions/002-phase3-visibility-break.md) - 상세 설계
- [V4_UNIFIED_DESIGN_SPEC.md](./V4_UNIFIED_DESIGN_SPEC.md) - v4 통합 설계
- [CHANGELOG.md](./CHANGELOG.md) - 변경 이력
- [PLAN.md](./PLAN.md) - 로드맵
---
*Phase 3 완료 - 2026-02-04*
*다음: Phase 4 (실제 컴포넌트 구현)*

View File

@ -6,11 +6,11 @@
## Quick Reference
### 총 컴포넌트 수: 15개 (🆕 줄바꿈 추가)
### 총 컴포넌트 수: 14개
| 분류 | 개수 | 컴포넌트 |
|------|------|----------|
| 레이아웃 | 4 | container, tab-panel, **spacer**, **break 🆕** |
| 레이아웃 | 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 |
@ -38,18 +38,17 @@
| 1 | pop-container | 레이아웃 뼈대 |
| 2 | pop-tab-panel | 정보 분류 |
| 3 | **pop-spacer** | **빈 공간 (정렬용)** |
| 4 | **pop-break 🆕** | **강제 줄바꿈 (Flexbox)** |
| 5 | pop-data-table | 대량 데이터 |
| 6 | pop-card-list | 시각적 목록 |
| 7 | pop-kpi-gauge | 목표 달성률 |
| 8 | pop-status-indicator | 상태 표시 |
| 9 | pop-number-pad | 수량 입력 |
| 10 | pop-barcode-scanner | 스캔 입력 |
| 11 | pop-form-field | 범용 입력 |
| 12 | pop-action-button | 작업 실행 |
| 13 | pop-timer | 시간 측정 |
| 14 | pop-alarm-list | 알람 관리 |
| 15 | pop-process-flow | 공정 현황 |
| 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 | 공정 현황 |
---
@ -110,107 +109,7 @@
---
## 4. pop-break (v4 전용) 🆕
역할: Flexbox에서 강제 줄바꿈을 위한 컴포넌트 (업계 표준: Figma Auto Layout의 줄바꿈과 동일)
| 기능 | 설명 |
|------|------|
| 강제 줄바꿈 | `flex-basis: 100%`로 다음 컴포넌트를 새 줄로 이동 |
| 모드별 표시 | visibility 속성으로 특정 모드에서만 줄바꿈 적용 |
| 시각적 표시 | 디자인 모드에서만 점선으로 표시 |
| 실제 화면 | 높이 0px (완전히 보이지 않음) |
### 동작 원리
```
Flexbox wrap: true 상태에서
flex-basis: 100%를 가진 요소 → 전체 너비 차지 → 다음 요소는 자동으로 새 줄로 이동
```
### 사용 예시
```
[필드A] [필드B] [줄바꿈] [필드C] [필드D]
결과:
┌────────────────────┐
│ [필드A] [필드B] │ ← 첫째 줄
│ [필드C] [필드D] │ ← 둘째 줄 (줄바꿈 후)
└────────────────────┘
```
### 모드별 줄바꿈
```typescript
// 줄바꿈 컴포넌트 설정
{
id: "break-1",
type: "pop-break",
visibility: {
tablet_landscape: false, // 태블릿: 줄바꿈 숨김 (한 줄)
mobile_portrait: true, // 모바일: 줄바꿈 표시 (두 줄)
}
}
// 결과
태블릿: [A] [B] [C] [D] (한 줄)
모바일: [A] [B] (두 줄)
[C] [D]
```
### 기본 설정
| 속성 | 기본값 |
|------|--------|
| width | fill (`flex-basis: 100%`) |
| height | 0px (높이 없음) |
| 디자인 모드 표시 | 점선 + "줄바꿈" 텍스트 (높이 16px) |
| 실제 모드 | 완전히 투명 (높이 0px) |
| flex-basis | 100% (핵심 속성) |
### CSS 구현
```css
/* 디자인 모드 */
.pop-break-design {
flex-basis: 100%;
width: 100%;
height: 16px;
border: 2px dashed #d1d5db;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
}
/* 실제 모드 */
.pop-break-runtime {
flex-basis: 100%;
width: 100%;
height: 0;
}
```
### 업계 비교
| 서비스 | 줄바꿈 방식 |
|--------|------------|
| Figma Auto Layout | "Wrap" 설정 + 수동 줄 분리 |
| Webflow Flexbox | "Wrap" + 100% width spacer |
| Framer | "Break" 컴포넌트 |
| **POP v4** | **pop-break (flex-basis: 100%)** |
### 주의사항
- 컨테이너의 `wrap: true` 설정 필수
- wrap이 false면 줄바꿈 무시됨
- visibility로 모드별 제어 가능
- 디자인 모드에서만 시각적으로 보임
---
## 5. pop-data-table
## 3. pop-data-table
역할: 대량 데이터 표시, 선택, 편집
@ -418,44 +317,4 @@ flex-basis: 100%를 가진 요소 → 전체 너비 차지 → 다음 요소는
---
## Phase 3 업데이트 (2026-02-04) 🆕
### 추가된 컴포넌트
#### pop-break (줄바꿈)
- **역할**: Flexbox에서 강제 줄바꿈
- **핵심 기술**: `flex-basis: 100%`
- **모드별 제어**: visibility 속성 지원
- **시각적 표시**: 디자인 모드에서만 점선 표시 (실제 높이 0px)
### 모든 컴포넌트 공통 추가 속성
#### visibility (모드별 표시/숨김)
```typescript
visibility?: {
tablet_landscape?: boolean;
tablet_portrait?: boolean;
mobile_landscape?: boolean;
mobile_portrait?: boolean;
}
```
**사용 예시**:
```typescript
// 모바일 전용 버튼
{
type: "pop-action-button",
visibility: {
tablet_landscape: false,
mobile_portrait: true,
}
}
```
### 참고 문서
- [decisions/002-phase3-visibility-break.md](./decisions/002-phase3-visibility-break.md) - 상세 설계
- [V4_UNIFIED_DESIGN_SPEC.md](./V4_UNIFIED_DESIGN_SPEC.md) - v4 통합 설계
---
*최종 업데이트: 2026-02-04 (Phase 3 완료)*
*최종 업데이트: 2026-01-29*

View File

@ -1,690 +0,0 @@
# Phase 3: Visibility + 줄바꿈 컴포넌트 구현
**날짜**: 2026-02-04
**상태**: 구현 완료 ✅
**관련 이슈**: 모드별 컴포넌트 표시/숨김, 강제 줄바꿈
---
## 📋 목표
Phase 2의 배치 고정 기능에 이어, 다음 기능들을 추가:
1. **모드별 컴포넌트 표시/숨김** (visibility)
2. **강제 줄바꿈 컴포넌트** (pop-break)
3. **컴포넌트 오버라이드 병합** (모드별 설정 변경)
---
## 🔍 문제 정의
### 문제 1: 모드별 컴포넌트 추가/삭제 불가
```
현재 상황:
- 모든 모드에서 같은 컴포넌트만 표시 가능
- 모바일 전용 버튼(예: "전화 걸기")을 추가할 수 없음
요구사항:
- 특정 모드에서만 컴포넌트 표시
- 다른 모드에서는 자동 숨김
```
### 문제 2: Flexbox에서 강제 줄바꿈 불가
```
현재 상황:
- wrap: true여도 컴포넌트가 공간을 채워야 줄바꿈
- [A] [B] [C] → 강제로 [A] [B] / [C] 불가능
요구사항:
- 사용자가 원하는 위치에서 강제 줄바꿈
- 디자인 모드에서 시각적으로 표시
```
### 문제 3: 컴포넌트 설정을 모드별로 변경 불가
```
현재 상황:
- 컨테이너 배치만 오버라이드 가능
- 리스트 컬럼 수, 버튼 스타일 등은 모든 모드 동일
요구사항 (확장성):
- 태블릿: 리스트 7개 컬럼
- 모바일: 리스트 3개 컬럼
```
---
## 💡 해결 방안
### 방안 A: children 배열 오버라이드 (추가/삭제)
```typescript
overrides: {
mobile_portrait: {
containers: {
root: {
children: ["comp1", "comp2", "mobile-only-button"] // 컴포넌트 추가
}
}
}
}
```
**장점**:
- 모드별로 완전히 다른 컴포넌트 구성 가능
- 유연성 극대화
**단점**:
- 데이터 동기화 복잡
- 삭제/추가 시 다른 모드에도 영향
- 순서 변경 시 충돌 가능
---
### 방안 B: visibility 속성 (표시/숨김) ✅ 채택
```typescript
interface PopComponentDefinitionV4 {
visibility?: {
tablet_landscape?: boolean;
tablet_portrait?: boolean;
mobile_landscape?: boolean;
mobile_portrait?: boolean;
};
}
```
**장점**:
- 단순하고 명확
- 컴포넌트는 항상 존재 (숨김만)
- 데이터 일관성 유지
**단점**:
- 완전히 다른 컴포넌트 추가는 불가능
- 많은 모드 전용 컴포넌트는 비효율적
---
### 최종 결정: 하이브리드 접근 ⭐
```typescript
1. visibility: 기본 기능 (Phase 3)
- 간단한 표시/숨김
- 줄바꿈 컴포넌트 제어
2. components 오버라이드: 고급 기능 (Phase 3 기반)
- 컴포넌트 설정 변경 (리스트 컬럼 수 등)
- 스타일 변경
3. children 오버라이드: 추후 고려
- 모드별 완전히 다른 구성 필요 시
```
---
## 🛠️ 구현 내용
### 1. 타입 정의 확장
#### pop-break 컴포넌트 추가
```typescript
export type PopComponentType =
| "pop-field"
| "pop-button"
| "pop-list"
| "pop-indicator"
| "pop-scanner"
| "pop-numpad"
| "pop-spacer"
| "pop-break"; // 🆕 줄바꿈
```
#### visibility 속성 추가
```typescript
export interface PopComponentDefinitionV4 {
id: string;
type: PopComponentType;
size: PopSizeConstraintV4;
// 🆕 모드별 표시/숨김
visibility?: {
tablet_landscape?: boolean; // undefined = true (기본 표시)
tablet_portrait?: boolean;
mobile_landscape?: boolean;
mobile_portrait?: boolean;
};
// 기존: 픽셀 기반 반응형
hideBelow?: number;
// 기타...
}
```
#### 기본 크기 설정
```typescript
const defaultSizes: Record<PopComponentType, PopSizeConstraintV4> = {
// ...
"pop-break": {
width: "fill", // 100% 너비 (flex-basis: 100%)
height: "fixed",
fixedHeight: 0, // 높이 0 (보이지 않음)
},
};
```
---
### 2. 렌더러 로직 개선
#### visibility 체크 함수
```typescript
const isComponentVisible = (component: PopComponentDefinitionV4): boolean => {
if (!component.visibility) return true; // 기본값: 표시
const modeVisibility = component.visibility[currentMode];
return modeVisibility !== false; // undefined도 true로 취급
};
```
**로직 설명**:
- `visibility` 속성이 없으면 → 모든 모드에서 표시
- `visibility.mobile_portrait === false` → 모바일 세로에서 숨김
- `visibility.mobile_portrait === undefined` → 모바일 세로에서 표시 (기본값)
---
#### 컴포넌트 오버라이드 병합
```typescript
const getMergedComponent = (
baseComponent: PopComponentDefinitionV4
): PopComponentDefinitionV4 => {
if (currentMode === "tablet_landscape") return baseComponent;
const componentOverride = overrides?.[currentMode]?.components?.[baseComponent.id];
if (!componentOverride) return baseComponent;
// 깊은 병합 (config, size)
return {
...baseComponent,
...componentOverride,
size: { ...baseComponent.size, ...componentOverride.size },
config: { ...baseComponent.config, ...componentOverride.config },
};
};
```
**병합 우선순위**:
1. `baseComponent` (기본값)
2. `overrides[currentMode].components[id]` (모드별 오버라이드)
3. 중첩 객체는 깊은 병합 (`size`, `config`)
**확장 가능성**:
- 리스트 컬럼 수 변경
- 버튼 스타일 변경
- 필드 표시 형식 변경
---
#### pop-break 전용 렌더링
```typescript
// pop-break 특수 처리
if (mergedComponent.type === "pop-break") {
return (
<div
key={componentId}
className={cn(
"w-full",
isDesignMode
? "h-4 border-2 border-dashed border-gray-300 rounded flex items-center justify-center cursor-pointer hover:border-gray-400"
: "h-0"
)}
style={{ flexBasis: "100%" }} // 핵심: 100% 너비로 줄바꿈 강제
onClick={() => onComponentClick?.(componentId)}
>
{isDesignMode && (
<span className="text-xs text-gray-400">줄바꿈</span>
)}
</div>
);
}
```
**동작 방식**:
- `flex-basis: 100%` → 컨테이너 전체 너비 차지
- 다음 컴포넌트는 자동으로 새 줄로 이동
- 디자인 모드: 점선 표시 (높이 16px)
- 실제 화면: 높이 0 (안 보임)
---
### 3. 삭제 함수 개선
#### 오버라이드 정리 로직
```typescript
export const removeComponentFromV4Layout = (
layout: PopLayoutDataV4,
componentId: string
): PopLayoutDataV4 => {
// 1. 컴포넌트 정의 삭제
const { [componentId]: _, ...remainingComponents } = layout.components;
// 2. root.children에서 제거
const newRoot = removeChildFromContainer(layout.root, componentId);
// 3. 🆕 모든 오버라이드에서 제거
const newOverrides = cleanupOverridesAfterDelete(layout.overrides, componentId);
return {
...layout,
root: newRoot,
components: remainingComponents,
overrides: newOverrides,
};
};
```
#### 오버라이드 정리 상세
```typescript
function cleanupOverridesAfterDelete(
overrides: PopLayoutDataV4["overrides"],
componentId: string
): PopLayoutDataV4["overrides"] {
if (!overrides) return undefined;
const newOverrides = { ...overrides };
for (const mode of Object.keys(newOverrides)) {
const override = newOverrides[mode];
if (!override) continue;
const updated = { ...override };
// containers.root.children에서 제거
if (updated.containers?.root?.children) {
updated.containers = {
...updated.containers,
root: {
...updated.containers.root,
children: updated.containers.root.children.filter(id => id !== componentId),
},
};
}
// components에서 제거
if (updated.components?.[componentId]) {
const { [componentId]: _, ...rest } = updated.components;
updated.components = Object.keys(rest).length > 0 ? rest : undefined;
}
// 빈 오버라이드 정리
if (!updated.containers && !updated.components) {
delete newOverrides[mode];
} else {
newOverrides[mode] = updated;
}
}
// 모든 오버라이드가 비었으면 undefined 반환
return Object.keys(newOverrides).length > 0 ? newOverrides : undefined;
}
```
**정리 항목**:
1. `overrides[mode].containers.root.children` - 컴포넌트 ID 제거
2. `overrides[mode].components[componentId]` - 컴포넌트 설정 제거
3. 빈 오버라이드 객체 삭제 (메모리 절약)
---
### 4. 속성 패널 UI
#### "표시" 탭 추가
```typescript
<TabsList>
<TabsTrigger value="size">크기</TabsTrigger>
<TabsTrigger value="settings">설정</TabsTrigger>
<TabsTrigger value="visibility">
<Eye className="h-3 w-3" />
표시
</TabsTrigger>
<TabsTrigger value="data">데이터</TabsTrigger>
</TabsList>
```
#### VisibilityForm 컴포넌트
```typescript
function VisibilityForm({ component, onUpdate }: VisibilityFormProps) {
const modes = [
{ key: "tablet_landscape", label: "태블릿 가로 (1024×768)" },
{ key: "tablet_portrait", label: "태블릿 세로 (768×1024)" },
{ key: "mobile_landscape", label: "모바일 가로 (667×375)" },
{ key: "mobile_portrait", label: "모바일 세로 (375×667)" },
];
return (
<div className="space-y-4">
<Label>모드별 표시 설정</Label>
<p className="text-xs text-muted-foreground">
체크 해제하면 해당 모드에서 컴포넌트가 숨겨집니다
</p>
<div className="space-y-2 rounded-lg border p-3">
{modes.map(({ key, label }) => {
const isChecked = component.visibility?.[key] !== false;
return (
<div key={key} className="flex items-center gap-2">
<input
type="checkbox"
checked={isChecked}
onChange={(e) => {
onUpdate?.({
visibility: {
...component.visibility,
[key]: e.target.checked,
},
});
}}
/>
<label>{label}</label>
{!isChecked && <span>(숨김)</span>}
</div>
);
})}
</div>
{/* 기존: 반응형 숨김 (픽셀 기반) */}
<div className="space-y-3">
<Label>반응형 숨김 (픽셀 기반)</Label>
<Input
type="number"
value={component.hideBelow || ""}
onChange={(e) =>
onUpdate?.({
hideBelow: e.target.value ? Number(e.target.value) : undefined,
})
}
placeholder="없음"
/>
<p className="text-xs text-muted-foreground">
예: 500 입력 시 화면 너비가 500px 이하면 자동 숨김
</p>
</div>
</div>
);
}
```
**UI 특징**:
- 체크박스로 직관적인 표시/숨김 제어
- 기본값은 모든 모드 체크 (표시)
- `hideBelow` (픽셀 기반)와 별도 유지
---
### 5. 팔레트 업데이트
```typescript
const COMPONENT_PALETTE = [
// ... 기존 컴포넌트들
{
type: "pop-break",
label: "줄바꿈",
icon: WrapText,
description: "강제 줄바꿈 (flex-basis: 100%)",
},
];
```
---
## 🎯 사용 예시
### 예시 1: 모바일 전용 버튼
```typescript
{
id: "call-button",
type: "pop-button",
label: "전화 걸기",
size: { width: "fixed", height: "fixed", fixedWidth: 120, fixedHeight: 48 },
visibility: {
tablet_landscape: false, // 태블릿 가로: 숨김
tablet_portrait: false, // 태블릿 세로: 숨김
mobile_landscape: true, // 모바일 가로: 표시
mobile_portrait: true, // 모바일 세로: 표시
},
}
```
**결과**:
- 태블릿: "전화 걸기" 버튼 안 보임
- 모바일: "전화 걸기" 버튼 보임
---
### 예시 2: 모드별 줄바꿈
```typescript
레이아웃:
[필드A] [필드B] [줄바꿈] [필드C] [필드D]
줄바꿈 컴포넌트 설정:
{
id: "break-1",
type: "pop-break",
visibility: {
tablet_landscape: false, // 태블릿: 줄바꿈 숨김 (한 줄)
mobile_portrait: true, // 모바일: 줄바꿈 표시 (두 줄)
},
}
```
**결과**:
```
태블릿 가로 (1024px):
┌─────────────────────────────────┐
│ [필드A] [필드B] [필드C] [필드D] │ ← 한 줄
└─────────────────────────────────┘
모바일 세로 (375px):
┌─────────────────┐
│ [필드A] [필드B] │ ← 첫 줄
│ [필드C] [필드D] │ ← 둘째 줄 (줄바꿈 적용)
└─────────────────┘
```
---
### 예시 3: 리스트 컬럼 수 변경 (확장 가능)
```typescript
// 기본 (태블릿 가로)
{
id: "product-list",
type: "pop-list",
config: {
columns: 7, // 7개 컬럼
}
}
// 오버라이드 (모바일 세로)
overrides: {
mobile_portrait: {
components: {
"product-list": {
config: {
columns: 3, // 3개 컬럼
}
}
}
}
}
```
**결과**:
- 태블릿: 7개 컬럼 표시
- 모바일: 3개 컬럼 표시 (병합됨)
---
## ✅ 테스트 시나리오
### 테스트 1: 줄바꿈 기본 동작
```
1. 팔레트에서 "줄바꿈" 드래그
2. [A] [B] [C] 사이에 드롭
3. 예상 결과: [A] [B] / [C]
4. 디자인 모드에서 점선 "줄바꿈" 표시 확인
5. 미리보기에서 줄바꿈이 안 보이는지 확인
```
### 테스트 2: 모드별 줄바꿈 표시
```
1. 줄바꿈 컴포넌트 추가
2. "표시" 탭 → 태블릿 모드 체크 해제
3. 태블릿 가로 모드: [A] [B] [C] (한 줄)
4. 모바일 세로 모드: [A] [B] / [C] (두 줄)
```
### 테스트 3: 컴포넌트 삭제 시 오버라이드 정리
```
1. 모바일 세로 모드에서 배치 고정
2. 컴포넌트 삭제
3. 저장 후 로드
4. DB 확인: overrides에서도 제거되었는지
```
### 테스트 4: 모드별 컴포넌트 숨김
```
1. "전화 걸기" 버튼 추가
2. "표시" 탭 → 태블릿 모드 체크 해제
3. 태블릿 가로: 버튼 안 보임
4. 모바일 세로: 버튼 보임
```
### 테스트 5: 속성 패널 UI
```
1. 컴포넌트 선택
2. "표시" 탭 클릭
3. 4개 체크박스 확인 (모두 체크됨)
4. 체크 해제 시 "(숨김)" 표시 확인
5. 저장 후 로드 → 체크 상태 유지
```
---
## 🔍 기술적 고려사항
### 1. 데이터 일관성
```
문제: 컴포넌트 삭제 시 오버라이드 잔여물
해결:
- cleanupOverridesAfterDelete() 함수
- containers.root.children 정리
- components 오버라이드 정리
- 빈 오버라이드 자동 삭제
```
### 2. 병합 우선순위
```
우선순위 (높음 → 낮음):
1. tempLayout (고정 전 미리보기)
2. overrides[currentMode].containers.root
3. overrides[currentMode].components[id]
4. layout.root (기본값)
5. layout.components[id] (기본값)
```
### 3. 성능 최적화
```typescript
// useMemo로 병합 결과 캐싱
const effectiveRoot = useMemo(() => getMergedRoot(), [tempLayout, overrides, currentMode]);
const mergedComponent = useMemo(() => getMergedComponent(baseComponent), [baseComponent, overrides, currentMode]);
```
### 4. 타입 안전성
```typescript
// visibility 키는 ViewportPreset에서만 허용
visibility?: {
[K in ViewportPreset]?: boolean;
};
// 컴파일 타임에 오타 방지
visibility.tablet_landspace = false; // ❌ 오타 감지!
visibility.tablet_landscape = false; // ✅ 정상
```
---
## 📊 영향 받는 파일
### 코드 파일
```
✅ frontend/components/pop/designer/types/pop-layout.ts
- PopComponentType 확장 (pop-break)
- PopComponentDefinitionV4.visibility 추가
- cleanupOverridesAfterDelete() 추가
✅ frontend/components/pop/designer/renderers/PopFlexRenderer.tsx
- isComponentVisible() 추가
- getMergedComponent() 추가
- pop-break 렌더링 추가
- ContainerRenderer props 확장
✅ frontend/components/pop/designer/panels/ComponentEditorPanelV4.tsx
- "표시" 탭 추가
- VisibilityForm 컴포넌트 추가
- COMPONENT_TYPE_LABELS 업데이트
✅ frontend/components/pop/designer/panels/ComponentPaletteV4.tsx
- "줄바꿈" 컴포넌트 추가
```
### 문서 파일
```
✅ popdocs/CHANGELOG.md
- Phase 3 완료 기록
✅ popdocs/PLAN.md
- Phase 3 체크 완료
- Phase 4 계획 추가
✅ popdocs/decisions/002-phase3-visibility-break.md (이 문서)
- 설계 결정 및 구현 상세
```
---
## 🚀 다음 단계
### Phase 4: 실제 컴포넌트 구현
```
우선순위:
1. pop-field (입력/표시 필드)
2. pop-button (액션 버튼)
3. pop-list (데이터 리스트)
4. pop-indicator (KPI 표시)
5. pop-scanner (바코드/QR)
6. pop-numpad (숫자 입력)
```
### 추가 개선 사항
```
1. 컴포넌트 오버라이드 UI
- 리스트 컬럼 수 조정
- 버튼 스타일 변경
- 필드 표시 형식 변경
2. "모든 모드에 적용" 기능
- 한 번에 모든 모드 체크/해제
3. 오버라이드 비교 뷰
- 기본값 vs 오버라이드 차이 표시
```
---
## 📝 결론
Phase 3를 통해 다음을 달성:
1. ✅ 모드별 컴포넌트 표시/숨김 제어
2. ✅ 강제 줄바꿈 컴포넌트 (Flexbox 한계 극복)
3. ✅ 컴포넌트 오버라이드 병합 (확장성 확보)
4. ✅ 데이터 일관성 유지 (삭제 시 정리)
이제 v4 레이아웃 시스템의 핵심 기능이 완성되었으며, 실제 컴포넌트 구현 단계로 진행할 수 있습니다.

View File

@ -1,143 +0,0 @@
# ADR-003: v5 CSS Grid 기반 그리드 시스템 채택
**날짜**: 2026-02-05
**상태**: 채택됨
**의사결정자**: 프로젝트 담당자, 상급자
---
## 배경
### 문제 상황
v4 Flexbox 기반 레이아웃으로 반응형 구현을 시도했으나 실패:
1. **배치 예측 불가능**: 컴포넌트가 자유롭게 움직이지만 원하는 위치에 안 감
2. **캔버스 방식의 한계**: "그리듯이" 배치하면 화면 크기별로 깨짐
3. **규칙 부재**: 어디에 뭘 배치해야 하는지 기준이 없음
### 상급자 피드백
> "이런 식이면 나중에 문제가 생긴다."
>
> "스크린의 픽셀 규격과 마진 간격 규칙을 설정해라.
> 큰 화면 디자인의 전체 프레임 규격과 사이즈 간격 규칙을 정한 다음에
> 거기에 컴포넌트를 끼워 맞추듯 우리의 규칙 내로 움직이게 바탕을 잡아라."
### 연구 내용
| 도구 | 핵심 특징 | 적용 가능 요소 |
|------|----------|---------------|
| **Softr** | 블록 기반, 제약 기반 레이아웃 | 컨테이너 슬롯 방식 |
| **Ant Design** | 24열 그리드, 8px 간격 | 그리드 시스템, 간격 규칙 |
| **Material Design** | 4/8/12열, 반응형 브레이크포인트 | 디바이스별 칸 수 |
---
## 결정
**CSS Grid 기반 그리드 시스템 (v5) 채택**
### 핵심 규칙
| 모드 | 화면 너비 | 칸 수 | 대상 디바이스 |
|------|----------|-------|--------------|
| mobile_portrait | ~599px | 4칸 | 4~6인치 모바일 |
| mobile_landscape | 600~839px | 6칸 | 7인치 모바일 |
| tablet_portrait | 840~1023px | 8칸 | 8~10인치 태블릿 |
| tablet_landscape | 1024px~ | 12칸 | 10~14인치 태블릿 (기본) |
### 컴포넌트 배치
```typescript
interface PopGridPosition {
col: number; // 시작 열 (1부터)
row: number; // 시작 행 (1부터)
colSpan: number; // 열 크기 (1~12)
rowSpan: number; // 행 크기 (1~)
}
```
### v4 대비 변경점
| 항목 | v4 (Flexbox) | v5 (Grid) |
|------|-------------|-----------|
| 배치 방식 | 흐름 기반 (자동) | 좌표 기반 (명시적) |
| 크기 단위 | 픽셀 (200px) | 칸 (colSpan: 3) |
| 예측성 | 낮음 | 높음 |
| 반응형 | 복잡한 규칙 | 칸 수 변환 |
---
## 대안 검토
### A. v4 Flexbox 유지 (기각)
- **장점**: 기존 코드 활용 가능
- **단점**: 상급자 지적한 문제 해결 안됨 (규칙 부재)
- **결과**: 기각
### B. 자유 배치 (절대 좌표) (기각)
- **장점**: 완전한 자유도
- **단점**: 반응형 불가능, 화면별로 전부 다시 배치 필요
- **결과**: 기각
### C. CSS Grid 그리드 시스템 (채택)
- **장점**:
- 규칙 기반으로 예측 가능
- 반응형 자동화 (12칸 → 4칸 변환)
- Material Design 표준 준수
- **단점**:
- 기존 v4 데이터 호환 불가
- 자유도 제한 (칸 단위로만)
- **결과**: **채택**
---
## 영향
### 변경 필요
- [x] 타입 정의 (`PopLayoutDataV5`, `PopGridPosition`)
- [x] 렌더러 (`PopRenderer.tsx` - CSS Grid)
- [x] 캔버스 (`PopCanvas.tsx` - 그리드 표시)
- [x] 유틸리티 (`gridUtils.ts` - 좌표 계산)
- [x] 레거시 삭제 (v1~v4 코드, 데이터)
### 호환성
- v1~v4 레이아웃: **삭제** (마이그레이션 없이 초기화)
- 새 화면: v5로만 생성
### 제한 사항
- 컴포넌트는 칸 단위로만 배치 (칸 사이 배치 불가)
- 12칸 기준으로 설계 후 다른 모드는 자동 변환
---
## 교훈
1. **규칙이 자유를 만든다**: 제약이 있어야 일관된 디자인 가능
2. **상급자 피드백 중요**: "프레임 규격 먼저" 조언이 핵심 방향 제시
3. **연구 후 결정**: Softr, Ant Design 분석이 구체적 방향 제시
4. **과감한 삭제**: 레거시 유지보다 깔끔한 재시작이 나음
---
## 참조
- Softr: https://www.softr.io
- Ant Design Grid: https://ant.design/components/grid
- Material Design Layout: https://m3.material.io/foundations/layout
- GRID_SYSTEM_DESIGN.md: 상세 설계 스펙
---
## 관련
- [ADR-001](./001-v4-constraint-based.md): v4 제약조건 기반 (이전 시도)
- [CHANGELOG 2026-02-05](../CHANGELOG.md#2026-02-05): 작업 내역
- [sessions/2026-02-05](../sessions/2026-02-05.md): 대화 기록

View File

@ -1,85 +0,0 @@
# 2026-02-05 작업 기록
## 요약
v5 그리드 시스템 통합 완료, popdocs 문서 구조 재정비
---
## 완료
### v5 통합 작업
- [x] 레거시 파일 삭제 (PopCanvasV4, PopFlexRenderer, PopLayoutRenderer 등)
- [x] 파일명 정규화 (V5 접미사 제거)
- [x] 뷰어 페이지 v5 전용으로 업데이트
- [x] 백엔드 screenManagementService v5 전용 단순화
- [x] DB 기존 레이아웃 데이터 삭제
### 문서 재정비 작업
- [x] SAVE_RULES.md 생성 (AI 저장/조회 규칙)
- [x] README.md 재작성 (진입점 역할)
- [x] STATUS.md 생성 (현재 상태)
- [x] PROBLEMS.md 생성 (문제-해결 색인)
- [x] INDEX.md 생성 (기능별 색인)
- [x] sessions/ 폴더 구조 도입
---
## 미완료
- [ ] 컴포넌트 팔레트 UI 추가 (PopDesigner.tsx 좌측)
- [ ] PopCanvas.tsx 타입 오류 수정 (line 76)
- [ ] ARCHITECTURE.md v5 기준 업데이트
- [ ] CHANGELOG.md 오늘 작업 추가
---
## 중단점
> **다음 작업자 참고**:
>
> 1. **타입 오류**: PopCanvas.tsx line 76
> - `}: PopCanvasV5Props)``}: PopCanvasProps)`로 변경
> - 인터페이스는 이미 `PopCanvasProps`로 정의됨 (line 48)
>
> 2. **팔레트 UI**: PopDesigner.tsx에 컴포넌트 팔레트 추가 필요
> - 위치: 좌측 ResizablePanel (현재 비어있음)
> - 참고: 이전 ComponentPaletteV4.tsx (삭제됨, archive에서 참고 가능)
> - DnD 타입: PopCanvas.tsx에 `DND_ITEM_TYPES` 인라인 정의됨
>
> 3. **문서**: ARCHITECTURE.md가 아직 v3/v4 기준임
---
## 대화 핵심
### v5 전환 배경
- **문제**: v4 Flexbox로 반응형 시도 → 배치 예측 불가능
- **상급자 피드백**: "스크린 규격과 마진 간격 규칙을 먼저 정해라"
- **연구**: Softr, Ant Design, Material Design 분석
- **결정**: CSS Grid 기반 그리드 시스템 채택
### popdocs 재정비 배경
- **문제**: 문서 구조가 AI 에이전트 진입점 역할 못함
- **해결**: Progressive Disclosure 적용, 저장/조회 규칙 명시화
- **참고**: 2025-2026 AI 컨텍스트 엔지니어링 최신 기법
### 핵심 결정
- Layer 1 (진입점): README, STATUS, SAVE_RULES
- Layer 2 (상세): CHANGELOG, PROBLEMS, INDEX 등
- Layer 3 (심화): decisions/, sessions/, archive/
---
## 관련 링크
- ADR: [decisions/003-v5-grid-system.md](../decisions/003-v5-grid-system.md)
- CHANGELOG: 오늘 작업 추가 필요
- 삭제된 파일 목록: FILES.md 하단 "삭제된 파일" 섹션
---
## 메모
- POPUPDATE.md (루트)는 별도로 유지 (전체 프로젝트 기록용)
- popdocs/는 POP 디자이너 개발 전용
- rangraph 연동 고려 (장기 기억 검색용)