ERP-node/docs/report-grid-system-implemen...

17 KiB

리포트 디자이너 그리드 시스템 구현 계획

개요

현재 자유 배치 방식의 리포트 디자이너를 그리드 기반 스냅 시스템으로 전환합니다. 안드로이드 홈 화면의 위젯 배치 방식과 유사하게, 모든 컴포넌트는 그리드에 맞춰서만 배치 및 크기 조절이 가능합니다.

목표

  1. 정렬된 레이아웃: 그리드 기반으로 요소들이 자동 정렬
  2. Word/PDF 변환 개선: 그리드 정보를 활용하여 정확한 문서 변환
  3. 직관적인 UI: 그리드 시각화를 통한 명확한 배치 가이드
  4. 사용자 제어: 그리드 크기, 가시성 등 사용자 설정 가능

핵심 개념

그리드 시스템

interface GridConfig {
  // 그리드 설정
  cellWidth: number; // 그리드 셀 너비 (px)
  cellHeight: number; // 그리드 셀 높이 (px)
  rows: number; // 세로 그리드 수 (계산값: pageHeight / cellHeight)
  columns: number; // 가로 그리드 수 (계산값: pageWidth / cellWidth)

  // 표시 설정
  visible: boolean; // 그리드 표시 여부
  snapToGrid: boolean; // 그리드 스냅 활성화 여부

  // 시각적 설정
  gridColor: string; // 그리드 선 색상
  gridOpacity: number; // 그리드 투명도 (0-1)
}

컴포넌트 위치/크기 (그리드 기반)

interface ComponentPosition {
  // 그리드 좌표 (셀 단위)
  gridX: number; // 시작 열 (0부터 시작)
  gridY: number; // 시작 행 (0부터 시작)
  gridWidth: number; // 차지하는 열 수
  gridHeight: number; // 차지하는 행 수

  // 실제 픽셀 좌표 (계산값)
  x: number; // gridX * cellWidth
  y: number; // gridY * cellHeight
  width: number; // gridWidth * cellWidth
  height: number; // gridHeight * cellHeight
}

구현 단계

Phase 1: 그리드 시스템 기반 구조

1.1 타입 정의

  • 파일: frontend/types/report.ts
  • 내용:
    • GridConfig 인터페이스 추가
    • ComponentConfiggridX, gridY, gridWidth, gridHeight 추가
    • ReportPagegridConfig 추가

1.2 Context 확장

  • 파일: frontend/contexts/ReportDesignerContext.tsx
  • 내용:
    • gridConfig 상태 추가
    • updateGridConfig() 함수 추가
    • snapToGrid() 유틸리티 함수 추가
    • 컴포넌트 추가/이동/리사이즈 시 그리드 스냅 적용

1.3 그리드 계산 유틸리티

  • 파일: frontend/lib/utils/gridUtils.ts (신규)

  • 내용:

    // 픽셀 좌표 → 그리드 좌표 변환
    export function pixelToGrid(pixel: number, cellSize: number): number;
    
    // 그리드 좌표 → 픽셀 좌표 변환
    export function gridToPixel(grid: number, cellSize: number): number;
    
    // 컴포넌트 위치/크기를 그리드에 스냅
    export function snapComponentToGrid(
      component: ComponentConfig,
      gridConfig: GridConfig
    ): ComponentConfig;
    
    // 그리드 충돌 감지
    export function detectGridCollision(
      component: ComponentConfig,
      otherComponents: ComponentConfig[]
    ): boolean;
    

Phase 2: 그리드 시각화

2.1 그리드 레이어 컴포넌트

  • 파일: frontend/components/report/designer/GridLayer.tsx (신규)
  • 내용:
    • Canvas 위에 그리드 선 렌더링
    • SVG 또는 Canvas API 사용
    • 그리드 크기/색상/투명도 적용
    • 줌/스크롤 시에도 정확한 위치 유지
interface GridLayerProps {
  gridConfig: GridConfig;
  pageWidth: number;
  pageHeight: number;
}

export function GridLayer({
  gridConfig,
  pageWidth,
  pageHeight,
}: GridLayerProps) {
  if (!gridConfig.visible) return null;

  // SVG로 그리드 선 렌더링
  return (
    <svg className="absolute inset-0 pointer-events-none">
      {/* 세로 선 */}
      {Array.from({ length: gridConfig.columns + 1 }).map((_, i) => (
        <line
          key={`v-${i}`}
          x1={i * gridConfig.cellWidth}
          y1={0}
          x2={i * gridConfig.cellWidth}
          y2={pageHeight}
          stroke={gridConfig.gridColor}
          strokeOpacity={gridConfig.opacity}
        />
      ))}
      {/* 가로 선 */}
      {Array.from({ length: gridConfig.rows + 1 }).map((_, i) => (
        <line
          key={`h-${i}`}
          x1={0}
          y1={i * gridConfig.cellHeight}
          x2={pageWidth}
          y2={i * gridConfig.cellHeight}
          stroke={gridConfig.gridColor}
          strokeOpacity={gridConfig.opacity}
        />
      ))}
    </svg>
  );
}

2.2 Canvas 통합

  • 파일: frontend/components/report/designer/ReportDesignerCanvas.tsx
  • 내용:
    • <GridLayer /> 추가
    • 컴포넌트 렌더링 시 그리드 기반 위치 사용

Phase 3: 드래그 앤 드롭 스냅

3.1 드래그 시 그리드 스냅

  • 파일: frontend/components/report/designer/ReportDesignerCanvas.tsx
  • 내용:
    • useDrop 훅 수정
    • 드롭 위치를 그리드에 스냅
    • 실시간 스냅 가이드 표시
const [, drop] = useDrop({
  accept: ["TEXT", "LABEL", "TABLE", "SIGNATURE", "STAMP"],
  drop: (item: any, monitor) => {
    const offset = monitor.getClientOffset();
    if (!offset) return;

    // 캔버스 상대 좌표 계산
    const canvasRect = canvasRef.current?.getBoundingClientRect();
    if (!canvasRect) return;

    let x = offset.x - canvasRect.left;
    let y = offset.y - canvasRect.top;

    // 그리드 스냅 적용
    if (gridConfig.snapToGrid) {
      const gridX = Math.round(x / gridConfig.cellWidth);
      const gridY = Math.round(y / gridConfig.cellHeight);
      x = gridX * gridConfig.cellWidth;
      y = gridY * gridConfig.cellHeight;
    }

    // 컴포넌트 추가
    addComponent({ type: item.type, x, y });
  },
});

3.2 리사이즈 시 그리드 스냅

  • 파일: frontend/components/report/designer/ComponentWrapper.tsx
  • 내용:
    • react-resizable 또는 react-rndsnap 설정 활용
    • 리사이즈 핸들 드래그 시 그리드 단위로만 크기 조절
<Rnd
  position={{ x: component.x, y: component.y }}
  size={{ width: component.width, height: component.height }}
  onDragStop={(e, d) => {
    let newX = d.x;
    let newY = d.y;

    if (gridConfig.snapToGrid) {
      const gridX = Math.round(newX / gridConfig.cellWidth);
      const gridY = Math.round(newY / gridConfig.cellHeight);
      newX = gridX * gridConfig.cellWidth;
      newY = gridY * gridConfig.cellHeight;
    }

    updateComponent(component.id, { x: newX, y: newY });
  }}
  onResizeStop={(e, direction, ref, delta, position) => {
    let newWidth = parseInt(ref.style.width);
    let newHeight = parseInt(ref.style.height);

    if (gridConfig.snapToGrid) {
      const gridWidth = Math.round(newWidth / gridConfig.cellWidth);
      const gridHeight = Math.round(newHeight / gridConfig.cellHeight);
      newWidth = gridWidth * gridConfig.cellWidth;
      newHeight = gridHeight * gridConfig.cellHeight;
    }

    updateComponent(component.id, {
      width: newWidth,
      height: newHeight,
      ...position,
    });
  }}
  grid={
    gridConfig.snapToGrid
      ? [gridConfig.cellWidth, gridConfig.cellHeight]
      : undefined
  }
/>

Phase 4: 그리드 설정 UI

4.1 그리드 설정 패널

  • 파일: frontend/components/report/designer/GridSettingsPanel.tsx (신규)
  • 내용:
    • 그리드 크기 조절 (cellWidth, cellHeight)
    • 그리드 표시/숨김 토글
    • 스냅 활성화/비활성화 토글
    • 그리드 색상/투명도 조절
export function GridSettingsPanel() {
  const { gridConfig, updateGridConfig } = useReportDesigner();

  return (
    <Card>
      <CardHeader>
        <CardTitle className="text-sm">그리드 설정</CardTitle>
      </CardHeader>
      <CardContent className="space-y-3">
        {/* 그리드 표시 */}
        <div className="flex items-center justify-between">
          <Label>그리드 표시</Label>
          <Switch
            checked={gridConfig.visible}
            onCheckedChange={(visible) => updateGridConfig({ visible })}
          />
        </div>

        {/* 스냅 활성화 */}
        <div className="flex items-center justify-between">
          <Label>그리드 스냅</Label>
          <Switch
            checked={gridConfig.snapToGrid}
            onCheckedChange={(snapToGrid) => updateGridConfig({ snapToGrid })}
          />
        </div>

        {/* 셀 크기 */}
        <div className="space-y-2">
          <Label> 너비 (px)</Label>
          <Input
            type="number"
            value={gridConfig.cellWidth}
            onChange={(e) =>
              updateGridConfig({ cellWidth: parseInt(e.target.value) })
            }
            min={10}
            max={100}
          />
        </div>

        <div className="space-y-2">
          <Label> 높이 (px)</Label>
          <Input
            type="number"
            value={gridConfig.cellHeight}
            onChange={(e) =>
              updateGridConfig({ cellHeight: parseInt(e.target.value) })
            }
            min={10}
            max={100}
          />
        </div>

        {/* 프리셋 */}
        <div className="space-y-2">
          <Label>프리셋</Label>
          <Select
            onValueChange={(value) => {
              const presets: Record<
                string,
                { cellWidth: number; cellHeight: number }
              > = {
                fine: { cellWidth: 10, cellHeight: 10 },
                medium: { cellWidth: 20, cellHeight: 20 },
                coarse: { cellWidth: 50, cellHeight: 50 },
              };
              updateGridConfig(presets[value]);
            }}
          >
            <SelectTrigger>
              <SelectValue placeholder="그리드 크기 선택" />
            </SelectTrigger>
            <SelectContent>
              <SelectItem value="fine">세밀 (10x10)</SelectItem>
              <SelectItem value="medium">중간 (20x20)</SelectItem>
              <SelectItem value="coarse">넓음 (50x50)</SelectItem>
            </SelectContent>
          </Select>
        </div>
      </CardContent>
    </Card>
  );
}

4.2 툴바에 그리드 토글 추가

  • 파일: frontend/components/report/designer/ReportDesignerToolbar.tsx
  • 내용:
    • 그리드 표시/숨김 버튼
    • 그리드 설정 모달 열기 버튼
    • 키보드 단축키 (G 키로 그리드 토글)

Phase 5: Word 변환 개선

5.1 그리드 기반 레이아웃 변환

  • 파일: frontend/components/report/designer/ReportPreviewModal.tsx
  • 내용:
    • 그리드 정보를 활용하여 더 정확한 테이블 레이아웃 생성
    • 그리드 행/열을 Word 테이블의 행/열로 매핑
const handleDownloadWord = async () => {
  // 그리드 기반으로 컴포넌트 배치 맵 생성
  const gridMap: (ComponentConfig | null)[][] = Array(gridConfig.rows)
    .fill(null)
    .map(() => Array(gridConfig.columns).fill(null));

  // 각 컴포넌트를 그리드 맵에 배치
  for (const component of components) {
    const gridX = Math.round(component.x / gridConfig.cellWidth);
    const gridY = Math.round(component.y / gridConfig.cellHeight);
    const gridWidth = Math.round(component.width / gridConfig.cellWidth);
    const gridHeight = Math.round(component.height / gridConfig.cellHeight);

    // 컴포넌트가 차지하는 모든 셀에 참조 저장
    for (let y = gridY; y < gridY + gridHeight; y++) {
      for (let x = gridX; x < gridX + gridWidth; x++) {
        if (y < gridConfig.rows && x < gridConfig.columns) {
          gridMap[y][x] = component;
        }
      }
    }
  }

  // 그리드 맵을 Word 테이블로 변환
  const tableRows: TableRow[] = [];

  for (let y = 0; y < gridConfig.rows; y++) {
    const cells: TableCell[] = [];
    let x = 0;

    while (x < gridConfig.columns) {
      const component = gridMap[y][x];

      if (!component) {
        // 빈 셀
        cells.push(new TableCell({ children: [new Paragraph("")] }));
        x++;
      } else {
        // 컴포넌트 셀
        const gridWidth = Math.round(component.width / gridConfig.cellWidth);
        const gridHeight = Math.round(component.height / gridConfig.cellHeight);

        const cell = createTableCell(component, gridWidth, gridHeight);
        if (cell) cells.push(cell);

        x += gridWidth;
      }
    }

    if (cells.length > 0) {
      tableRows.push(new TableRow({ children: cells }));
    }
  }

  // ... Word 문서 생성
};

Phase 6: 데이터 마이그레이션

6.1 기존 레이아웃 자동 변환

  • 파일: frontend/lib/utils/layoutMigration.ts (신규)
  • 내용:
    • 기존 절대 위치 데이터를 그리드 기반으로 변환
    • 가장 가까운 그리드 셀에 스냅
    • 마이그레이션 로그 생성
export function migrateLayoutToGrid(
  layout: ReportLayoutConfig,
  gridConfig: GridConfig
): ReportLayoutConfig {
  return {
    ...layout,
    pages: layout.pages.map((page) => ({
      ...page,
      gridConfig,
      components: page.components.map((component) => {
        // 픽셀 좌표를 그리드 좌표로 변환
        const gridX = Math.round(component.x / gridConfig.cellWidth);
        const gridY = Math.round(component.y / gridConfig.cellHeight);
        const gridWidth = Math.max(
          1,
          Math.round(component.width / gridConfig.cellWidth)
        );
        const gridHeight = Math.max(
          1,
          Math.round(component.height / gridConfig.cellHeight)
        );

        return {
          ...component,
          gridX,
          gridY,
          gridWidth,
          gridHeight,
          x: gridX * gridConfig.cellWidth,
          y: gridY * gridConfig.cellHeight,
          width: gridWidth * gridConfig.cellWidth,
          height: gridHeight * gridConfig.cellHeight,
        };
      }),
    })),
  };
}

6.2 마이그레이션 UI

  • 파일: frontend/components/report/designer/MigrationModal.tsx (신규)
  • 내용:
    • 기존 리포트 로드 시 마이그레이션 필요 여부 체크
    • 마이그레이션 전/후 미리보기
    • 사용자 확인 후 적용

데이터베이스 스키마 변경

report_layout_pages 테이블

ALTER TABLE report_layout_pages
ADD COLUMN grid_cell_width INTEGER DEFAULT 20,
ADD COLUMN grid_cell_height INTEGER DEFAULT 20,
ADD COLUMN grid_visible BOOLEAN DEFAULT true,
ADD COLUMN grid_snap_enabled BOOLEAN DEFAULT true,
ADD COLUMN grid_color VARCHAR(7) DEFAULT '#e5e7eb',
ADD COLUMN grid_opacity DECIMAL(3,2) DEFAULT 0.5;

report_layout_components 테이블

ALTER TABLE report_layout_components
ADD COLUMN grid_x INTEGER,
ADD COLUMN grid_y INTEGER,
ADD COLUMN grid_width INTEGER,
ADD COLUMN grid_height INTEGER;

-- 기존 데이터 마이그레이션
UPDATE report_layout_components
SET
  grid_x = ROUND(position_x / 20.0),
  grid_y = ROUND(position_y / 20.0),
  grid_width = GREATEST(1, ROUND(width / 20.0)),
  grid_height = GREATEST(1, ROUND(height / 20.0))
WHERE grid_x IS NULL;

테스트 계획

단위 테스트

  • gridUtils.ts의 모든 함수 테스트
  • 그리드 좌표 ↔ 픽셀 좌표 변환 정확성
  • 충돌 감지 로직

통합 테스트

  • 드래그 앤 드롭 시 그리드 스냅 동작
  • 리사이즈 시 그리드 스냅 동작
  • 그리드 크기 변경 시 컴포넌트 재배치

E2E 테스트

  • 새 리포트 생성 및 그리드 설정
  • 기존 리포트 마이그레이션
  • Word 다운로드 시 레이아웃 정확성

예상 개발 일정

  • Phase 1: 그리드 시스템 기반 구조 (2일)
  • Phase 2: 그리드 시각화 (1일)
  • Phase 3: 드래그 앤 드롭 스냅 (2일)
  • Phase 4: 그리드 설정 UI (1일)
  • Phase 5: Word 변환 개선 (2일)
  • Phase 6: 데이터 마이그레이션 (1일)
  • 테스트 및 디버깅: (2일)

총 예상 기간: 11일

기술적 고려사항

성능 최적화

  • 그리드 렌더링: SVG 대신 Canvas API 고려 (많은 셀의 경우)
  • 메모이제이션: 그리드 계산 결과 캐싱
  • 가상화: 큰 페이지에서 보이는 영역만 렌더링

사용자 경험

  • 실시간 스냅 가이드: 드래그 중 스냅될 위치 미리 표시
  • 키보드 단축키: 방향키로 그리드 단위 이동, Shift+방향키로 픽셀 단위 미세 조정
  • 언두/리두: 그리드 스냅 적용 전/후 상태 저장

하위 호환성

  • 기존 리포트는 자동 마이그레이션 제공
  • 마이그레이션 옵션: 자동 / 수동 선택 가능
  • 레거시 모드: 그리드 없이 자유 배치 가능 (옵션)

추가 기능 (향후 확장)

스마트 가이드

  • 다른 컴포넌트와 정렬 시 가이드 라인 표시
  • 균등 간격 가이드

그리드 템플릿

  • 자주 사용하는 그리드 레이아웃 템플릿 제공
  • 문서 종류별 프리셋 (계약서, 보고서, 송장 등)

그리드 병합

  • 여러 그리드 셀을 하나로 병합
  • 복잡한 레이아웃 지원

참고 자료

  • Android Home Screen Widget System
  • Microsoft Word Table Layout
  • CSS Grid Layout
  • Figma Auto Layout