ERP-node/popdocs/archive/GRID_CODING_PLAN.md

19 KiB

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

추가할 코드:

// ========================================
// 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

// ========================================
// 그리드 브레이크포인트
// ========================================

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

// ========================================
// 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

"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

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