592 lines
17 KiB
Markdown
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
|