# 리포트 디자이너 그리드 시스템 구현 계획 ## 개요 현재 자유 배치 방식의 리포트 디자이너를 **그리드 기반 스냅 시스템**으로 전환합니다. 안드로이드 홈 화면의 위젯 배치 방식과 유사하게, 모든 컴포넌트는 그리드에 맞춰서만 배치 및 크기 조절이 가능합니다. ## 목표 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 ( {/* 세로 선 */} {Array.from({ length: gridConfig.columns + 1 }).map((_, i) => ( ))} {/* 가로 선 */} {Array.from({ length: gridConfig.rows + 1 }).map((_, i) => ( ))} ); } ``` #### 2.2 Canvas 통합 - **파일**: `frontend/components/report/designer/ReportDesignerCanvas.tsx` - **내용**: - `` 추가 - 컴포넌트 렌더링 시 그리드 기반 위치 사용 ### 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 { 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 ( 그리드 설정 {/* 그리드 표시 */}
updateGridConfig({ visible })} />
{/* 스냅 활성화 */}
updateGridConfig({ snapToGrid })} />
{/* 셀 크기 */}
updateGridConfig({ cellWidth: parseInt(e.target.value) }) } min={10} max={100} />
updateGridConfig({ cellHeight: parseInt(e.target.value) }) } min={10} max={100} />
{/* 프리셋 */}
); } ``` #### 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