ERP-node/popdocs/archive/GRID_CODING_PLAN.md

764 lines
19 KiB
Markdown

# 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 타입 정의)*