Files
vexplor/docs/report-grid-system-implementation-plan.md

592 lines
17 KiB
Markdown

# 리포트 디자이너 그리드 시스템 구현 계획
## 개요
현재 자유 배치 방식의 리포트 디자이너를 **그리드 기반 스냅 시스템**으로 전환합니다.
안드로이드 홈 화면의 위젯 배치 방식과 유사하게, 모든 컴포넌트는 그리드에 맞춰서만 배치 및 크기 조절이 가능합니다.
## 목표
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