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

## 개요

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

## 목표

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

## 핵심 개념

### 그리드 시스템

```typescript
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)
}
```

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

```typescript
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` 인터페이스 추가
  - `ComponentConfig`에 `gridX`, `gridY`, `gridWidth`, `gridHeight` 추가
  - `ReportPage`에 `gridConfig` 추가

#### 1.2 Context 확장

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

#### 1.3 그리드 계산 유틸리티

- **파일**: `frontend/lib/utils/gridUtils.ts` (신규)
- **내용**:

  ```typescript
  // 픽셀 좌표 → 그리드 좌표 변환
  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 사용
  - 그리드 크기/색상/투명도 적용
  - 줌/스크롤 시에도 정확한 위치 유지

```tsx
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` 훅 수정
  - 드롭 위치를 그리드에 스냅
  - 실시간 스냅 가이드 표시

```typescript
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-rnd`의 `snap` 설정 활용
  - 리사이즈 핸들 드래그 시 그리드 단위로만 크기 조절

```typescript
<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)
  - 그리드 표시/숨김 토글
  - 스냅 활성화/비활성화 토글
  - 그리드 색상/투명도 조절

```tsx
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 테이블의 행/열로 매핑

```typescript
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` (신규)
- **내용**:
  - 기존 절대 위치 데이터를 그리드 기반으로 변환
  - 가장 가까운 그리드 셀에 스냅
  - 마이그레이션 로그 생성

```typescript
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 테이블

```sql
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 테이블

```sql
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
