레포트관리에 그리드 시스템 1차 적용(2차적인 개선 필요)
This commit is contained in:
parent
71eb308bba
commit
32024a6d70
|
|
@ -0,0 +1,591 @@
|
|||
# 리포트 디자이너 그리드 시스템 구현 계획
|
||||
|
||||
## 개요
|
||||
|
||||
현재 자유 배치 방식의 리포트 디자이너를 **그리드 기반 스냅 시스템**으로 전환합니다.
|
||||
안드로이드 홈 화면의 위젯 배치 방식과 유사하게, 모든 컴포넌트는 그리드에 맞춰서만 배치 및 크기 조절이 가능합니다.
|
||||
|
||||
## 목표
|
||||
|
||||
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
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
"use client";
|
||||
|
||||
import { GridConfig } from "@/types/report";
|
||||
|
||||
interface GridLayerProps {
|
||||
gridConfig: GridConfig;
|
||||
pageWidth: number;
|
||||
pageHeight: number;
|
||||
}
|
||||
|
||||
export function GridLayer({ gridConfig, pageWidth, pageHeight }: GridLayerProps) {
|
||||
if (!gridConfig.visible) return null;
|
||||
|
||||
const { cellWidth, cellHeight, columns, rows, gridColor, gridOpacity } = gridConfig;
|
||||
|
||||
// SVG로 그리드 선 렌더링
|
||||
return (
|
||||
<svg className="pointer-events-none absolute inset-0" width={pageWidth} height={pageHeight} style={{ zIndex: 0 }}>
|
||||
{/* 세로 선 */}
|
||||
{Array.from({ length: columns + 1 }).map((_, i) => (
|
||||
<line
|
||||
key={`v-${i}`}
|
||||
x1={i * cellWidth}
|
||||
y1={0}
|
||||
x2={i * cellWidth}
|
||||
y2={pageHeight}
|
||||
stroke={gridColor}
|
||||
strokeOpacity={gridOpacity}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 가로 선 */}
|
||||
{Array.from({ length: rows + 1 }).map((_, i) => (
|
||||
<line
|
||||
key={`h-${i}`}
|
||||
x1={0}
|
||||
y1={i * cellHeight}
|
||||
x2={pageWidth}
|
||||
y2={i * cellHeight}
|
||||
stroke={gridColor}
|
||||
strokeOpacity={gridOpacity}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
"use client";
|
||||
|
||||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
|
||||
export function GridSettingsPanel() {
|
||||
const { gridConfig, updateGridConfig } = useReportDesigner();
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">그리드 설정</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* 그리드 표시 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">그리드 표시</Label>
|
||||
<Switch checked={gridConfig.visible} onCheckedChange={(visible) => updateGridConfig({ visible })} />
|
||||
</div>
|
||||
|
||||
{/* 스냅 활성화 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">그리드 스냅</Label>
|
||||
<Switch checked={gridConfig.snapToGrid} onCheckedChange={(snapToGrid) => updateGridConfig({ snapToGrid })} />
|
||||
</div>
|
||||
|
||||
{/* 프리셋 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">프리셋</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 className="h-8">
|
||||
<SelectValue placeholder="그리드 크기 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="fine">세밀 (10x10)</SelectItem>
|
||||
<SelectItem value="medium">중간 (20x20)</SelectItem>
|
||||
<SelectItem value="coarse">넓음 (50x50)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 셀 너비 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">셀 너비</Label>
|
||||
<span className="text-xs text-gray-500">{gridConfig.cellWidth}px</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[gridConfig.cellWidth]}
|
||||
onValueChange={([value]) => updateGridConfig({ cellWidth: value })}
|
||||
min={5}
|
||||
max={100}
|
||||
step={5}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 셀 높이 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">셀 높이</Label>
|
||||
<span className="text-xs text-gray-500">{gridConfig.cellHeight}px</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[gridConfig.cellHeight]}
|
||||
onValueChange={([value]) => updateGridConfig({ cellHeight: value })}
|
||||
min={5}
|
||||
max={100}
|
||||
step={5}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 그리드 투명도 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">투명도</Label>
|
||||
<span className="text-xs text-gray-500">{Math.round(gridConfig.gridOpacity * 100)}%</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[gridConfig.gridOpacity * 100]}
|
||||
onValueChange={([value]) => updateGridConfig({ gridOpacity: value / 100 })}
|
||||
min={10}
|
||||
max={100}
|
||||
step={10}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 그리드 색상 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">그리드 색상</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="color"
|
||||
value={gridConfig.gridColor}
|
||||
onChange={(e) => updateGridConfig({ gridColor: e.target.value })}
|
||||
className="h-8 w-16 cursor-pointer"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={gridConfig.gridColor}
|
||||
onChange={(e) => updateGridConfig({ gridColor: e.target.value })}
|
||||
className="h-8 flex-1 font-mono text-xs"
|
||||
placeholder="#e5e7eb"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 그리드 정보 */}
|
||||
<div className="rounded border bg-gray-50 p-2 text-xs text-gray-600">
|
||||
<div className="flex justify-between">
|
||||
<span>행:</span>
|
||||
<span className="font-mono">{gridConfig.rows}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>열:</span>
|
||||
<span className="font-mono">{gridConfig.columns}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
|||
import { ComponentConfig } from "@/types/report";
|
||||
import { CanvasComponent } from "./CanvasComponent";
|
||||
import { Ruler } from "./Ruler";
|
||||
import { GridLayer } from "./GridLayer";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
export function ReportDesignerCanvas() {
|
||||
|
|
@ -32,6 +33,7 @@ export function ReportDesignerCanvas() {
|
|||
undo,
|
||||
redo,
|
||||
showRuler,
|
||||
gridConfig,
|
||||
} = useReportDesigner();
|
||||
|
||||
const [{ isOver }, drop] = useDrop(() => ({
|
||||
|
|
@ -331,16 +333,16 @@ export function ReportDesignerCanvas() {
|
|||
style={{
|
||||
width: `${canvasWidth}mm`,
|
||||
minHeight: `${canvasHeight}mm`,
|
||||
backgroundImage: showGrid
|
||||
? `
|
||||
linear-gradient(to right, #e5e7eb 1px, transparent 1px),
|
||||
linear-gradient(to bottom, #e5e7eb 1px, transparent 1px)
|
||||
`
|
||||
: undefined,
|
||||
backgroundSize: showGrid ? `${gridSize}px ${gridSize}px` : undefined,
|
||||
}}
|
||||
onClick={handleCanvasClick}
|
||||
>
|
||||
{/* 그리드 레이어 */}
|
||||
<GridLayer
|
||||
gridConfig={gridConfig}
|
||||
pageWidth={canvasWidth * 3.7795} // mm to px
|
||||
pageHeight={canvasHeight * 3.7795}
|
||||
/>
|
||||
|
||||
{/* 페이지 여백 가이드 */}
|
||||
{currentPage && (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
|||
import { QueryManager } from "./QueryManager";
|
||||
import { SignaturePad } from "./SignaturePad";
|
||||
import { SignatureGenerator } from "./SignatureGenerator";
|
||||
import { GridSettingsPanel } from "./GridSettingsPanel";
|
||||
import { reportApi } from "@/lib/api/reportApi";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
|
|
@ -102,7 +103,7 @@ export function ReportDesignerRightPanel() {
|
|||
<div className="w-[450px] border-l bg-white">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="h-full">
|
||||
<div className="border-b p-2">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsList className="grid w-full grid-cols-4">
|
||||
<TabsTrigger value="page" className="gap-1 text-xs">
|
||||
<Settings className="h-3 w-3" />
|
||||
페이지
|
||||
|
|
@ -111,6 +112,10 @@ export function ReportDesignerRightPanel() {
|
|||
<Settings className="h-3 w-3" />
|
||||
속성
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="grid" className="gap-1 text-xs">
|
||||
<Settings className="h-3 w-3" />
|
||||
그리드
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="queries" className="gap-1 text-xs">
|
||||
<Database className="h-3 w-3" />
|
||||
쿼리
|
||||
|
|
@ -1396,6 +1401,15 @@ export function ReportDesignerRightPanel() {
|
|||
</TabsContent>
|
||||
|
||||
{/* 쿼리 탭 */}
|
||||
{/* 그리드 탭 */}
|
||||
<TabsContent value="grid" className="mt-0 h-[calc(100vh-120px)]">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 p-4">
|
||||
<GridSettingsPanel />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="queries" className="mt-0 h-[calc(100vh-120px)]">
|
||||
<QueryManager />
|
||||
</TabsContent>
|
||||
|
|
|
|||
|
|
@ -13,7 +13,21 @@ import { Printer, FileDown, FileText } from "lucide-react";
|
|||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||
import { useState } from "react";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Document, Packer, Paragraph, TextRun, Table, TableCell, TableRow, WidthType } from "docx";
|
||||
// @ts-ignore - docx 라이브러리 타입 이슈
|
||||
import {
|
||||
Document,
|
||||
Packer,
|
||||
Paragraph,
|
||||
TextRun,
|
||||
Table,
|
||||
TableCell,
|
||||
TableRow,
|
||||
WidthType,
|
||||
ImageRun,
|
||||
AlignmentType,
|
||||
VerticalAlign,
|
||||
convertInchesToTwip,
|
||||
} from "docx";
|
||||
import { getFullImageUrl } from "@/lib/api/client";
|
||||
|
||||
interface ReportPreviewModalProps {
|
||||
|
|
@ -268,43 +282,104 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
|
|||
});
|
||||
};
|
||||
|
||||
// WORD 다운로드
|
||||
const handleDownloadWord = async () => {
|
||||
setIsExporting(true);
|
||||
try {
|
||||
// 컴포넌트를 Paragraph로 변환
|
||||
const paragraphs: (Paragraph | Table)[] = [];
|
||||
// Base64를 Uint8Array로 변환
|
||||
const base64ToUint8Array = (base64: string): Uint8Array => {
|
||||
const base64Data = base64.split(",")[1] || base64;
|
||||
const binaryString = atob(base64Data);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
};
|
||||
|
||||
// 모든 페이지의 컴포넌트 수집
|
||||
const allComponents = layoutConfig.pages
|
||||
.sort((a, b) => a.page_order - b.page_order)
|
||||
.flatMap((page) => page.components);
|
||||
// 컴포넌트를 TableCell로 변환
|
||||
const createTableCell = (component: any, pageWidth: number, widthPercent?: number): TableCell | null => {
|
||||
const cellWidth = widthPercent || 100;
|
||||
|
||||
// Y 좌표로 정렬
|
||||
const sortedComponents = [...allComponents].sort((a, b) => a.y - b.y);
|
||||
|
||||
for (const component of sortedComponents) {
|
||||
if (component.type === "text" || component.type === "label") {
|
||||
const value = getComponentValue(component);
|
||||
paragraphs.push(
|
||||
return new TableCell({
|
||||
children: [
|
||||
new Paragraph({
|
||||
children: [
|
||||
new TextRun({
|
||||
text: value,
|
||||
size: (component.fontSize || 13) * 2, // pt to half-pt
|
||||
size: (component.fontSize || 13) * 2,
|
||||
color: component.fontColor?.replace("#", "") || "000000",
|
||||
bold: component.fontWeight === "bold",
|
||||
}),
|
||||
],
|
||||
spacing: {
|
||||
after: 200,
|
||||
alignment:
|
||||
component.textAlign === "center"
|
||||
? AlignmentType.CENTER
|
||||
: component.textAlign === "right"
|
||||
? AlignmentType.RIGHT
|
||||
: AlignmentType.LEFT,
|
||||
}),
|
||||
],
|
||||
width: { size: cellWidth, type: WidthType.PERCENTAGE },
|
||||
verticalAlign: VerticalAlign.CENTER,
|
||||
borders: {
|
||||
top: { style: 0, size: 0, color: "FFFFFF" },
|
||||
bottom: { style: 0, size: 0, color: "FFFFFF" },
|
||||
left: { style: 0, size: 0, color: "FFFFFF" },
|
||||
right: { style: 0, size: 0, color: "FFFFFF" },
|
||||
},
|
||||
});
|
||||
} else if (component.type === "signature" || component.type === "stamp") {
|
||||
if (component.imageUrl) {
|
||||
try {
|
||||
const imageData = base64ToUint8Array(component.imageUrl);
|
||||
return new TableCell({
|
||||
children: [
|
||||
new Paragraph({
|
||||
children: [
|
||||
new ImageRun({
|
||||
data: imageData,
|
||||
transformation: {
|
||||
width: component.width || 150,
|
||||
height: component.height || 50,
|
||||
},
|
||||
}),
|
||||
);
|
||||
],
|
||||
alignment: AlignmentType.CENTER,
|
||||
}),
|
||||
],
|
||||
width: { size: cellWidth, type: WidthType.PERCENTAGE },
|
||||
verticalAlign: VerticalAlign.CENTER,
|
||||
borders: {
|
||||
top: { style: 0, size: 0, color: "FFFFFF" },
|
||||
bottom: { style: 0, size: 0, color: "FFFFFF" },
|
||||
left: { style: 0, size: 0, color: "FFFFFF" },
|
||||
right: { style: 0, size: 0, color: "FFFFFF" },
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return new TableCell({
|
||||
children: [
|
||||
new Paragraph({
|
||||
children: [
|
||||
new TextRun({
|
||||
text: `[${component.type === "signature" ? "서명" : "도장"}]`,
|
||||
size: 24,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
width: { size: cellWidth, type: WidthType.PERCENTAGE },
|
||||
borders: {
|
||||
top: { style: 0, size: 0, color: "FFFFFF" },
|
||||
bottom: { style: 0, size: 0, color: "FFFFFF" },
|
||||
left: { style: 0, size: 0, color: "FFFFFF" },
|
||||
right: { style: 0, size: 0, color: "FFFFFF" },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (component.type === "table" && component.queryId) {
|
||||
const queryResult = getQueryResult(component.queryId);
|
||||
if (queryResult && queryResult.rows.length > 0) {
|
||||
// 테이블 헤더
|
||||
const headerCells = queryResult.fields.map(
|
||||
(field) =>
|
||||
new TableCell({
|
||||
|
|
@ -313,7 +388,6 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
|
|||
}),
|
||||
);
|
||||
|
||||
// 테이블 행
|
||||
const dataRows = queryResult.rows.map(
|
||||
(row) =>
|
||||
new TableRow({
|
||||
|
|
@ -331,19 +405,140 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
|
|||
width: { size: 100, type: WidthType.PERCENTAGE },
|
||||
});
|
||||
|
||||
paragraphs.push(table);
|
||||
return new TableCell({
|
||||
children: [table],
|
||||
width: { size: cellWidth, type: WidthType.PERCENTAGE },
|
||||
borders: {
|
||||
top: { style: 0, size: 0, color: "FFFFFF" },
|
||||
bottom: { style: 0, size: 0, color: "FFFFFF" },
|
||||
left: { style: 0, size: 0, color: "FFFFFF" },
|
||||
right: { style: 0, size: 0, color: "FFFFFF" },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// WORD 다운로드
|
||||
const handleDownloadWord = async () => {
|
||||
setIsExporting(true);
|
||||
try {
|
||||
// 페이지별로 섹션 생성
|
||||
const sections = layoutConfig.pages
|
||||
.sort((a, b) => a.page_order - b.page_order)
|
||||
.map((page) => {
|
||||
// 페이지 크기 설정 (A4 기준)
|
||||
const pageWidth = convertInchesToTwip(8.27); // A4 width in inches
|
||||
const pageHeight = convertInchesToTwip(11.69); // A4 height in inches
|
||||
const marginTop = convertInchesToTwip(page.margins.top / 96); // px to inches (96 DPI)
|
||||
const marginBottom = convertInchesToTwip(page.margins.bottom / 96);
|
||||
const marginLeft = convertInchesToTwip(page.margins.left / 96);
|
||||
const marginRight = convertInchesToTwip(page.margins.right / 96);
|
||||
|
||||
// 페이지 내 컴포넌트를 Y좌표 기준으로 정렬
|
||||
const sortedComponents = [...page.components].sort((a, b) => {
|
||||
// Y좌표 우선, 같으면 X좌표
|
||||
if (Math.abs(a.y - b.y) < 5) {
|
||||
return a.x - b.x;
|
||||
}
|
||||
return a.y - b.y;
|
||||
});
|
||||
|
||||
// 컴포넌트들을 행으로 그룹화 (같은 Y 범위 = 같은 행)
|
||||
const rows: Array<Array<(typeof sortedComponents)[0]>> = [];
|
||||
const rowTolerance = 20; // Y 좌표 허용 오차
|
||||
|
||||
for (const component of sortedComponents) {
|
||||
const existingRow = rows.find((row) => Math.abs(row[0].y - component.y) < rowTolerance);
|
||||
if (existingRow) {
|
||||
existingRow.push(component);
|
||||
} else {
|
||||
rows.push([component]);
|
||||
}
|
||||
}
|
||||
|
||||
// 각 행 내에서 X좌표로 정렬
|
||||
rows.forEach((row) => row.sort((a, b) => a.x - b.x));
|
||||
|
||||
// 페이지 전체를 큰 테이블로 구성 (레이아웃 제어용)
|
||||
const tableRows: TableRow[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
if (row.length === 1) {
|
||||
// 단일 컴포넌트 - 전체 너비 사용
|
||||
const component = row[0];
|
||||
const cell = createTableCell(component, pageWidth);
|
||||
if (cell) {
|
||||
tableRows.push(
|
||||
new TableRow({
|
||||
children: [cell],
|
||||
height: { value: component.height * 15, rule: 1 }, // 최소 높이 설정
|
||||
}),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// 여러 컴포넌트 - 가로 배치
|
||||
const cells: TableCell[] = [];
|
||||
const totalWidth = row.reduce((sum, c) => sum + c.width, 0);
|
||||
|
||||
for (const component of row) {
|
||||
const widthPercent = (component.width / totalWidth) * 100;
|
||||
const cell = createTableCell(component, pageWidth, widthPercent);
|
||||
if (cell) {
|
||||
cells.push(cell);
|
||||
}
|
||||
}
|
||||
|
||||
if (cells.length > 0) {
|
||||
const maxHeight = Math.max(...row.map((c) => c.height));
|
||||
tableRows.push(
|
||||
new TableRow({
|
||||
children: cells,
|
||||
height: { value: maxHeight * 15, rule: 1 },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
properties: {
|
||||
page: {
|
||||
width: pageWidth,
|
||||
height: pageHeight,
|
||||
margin: {
|
||||
top: marginTop,
|
||||
bottom: marginBottom,
|
||||
left: marginLeft,
|
||||
right: marginRight,
|
||||
},
|
||||
},
|
||||
},
|
||||
children:
|
||||
tableRows.length > 0
|
||||
? [
|
||||
new Table({
|
||||
rows: tableRows,
|
||||
width: { size: 100, type: WidthType.PERCENTAGE },
|
||||
borders: {
|
||||
top: { style: 0, size: 0, color: "FFFFFF" },
|
||||
bottom: { style: 0, size: 0, color: "FFFFFF" },
|
||||
left: { style: 0, size: 0, color: "FFFFFF" },
|
||||
right: { style: 0, size: 0, color: "FFFFFF" },
|
||||
insideHorizontal: { style: 0, size: 0, color: "FFFFFF" },
|
||||
insideVertical: { style: 0, size: 0, color: "FFFFFF" },
|
||||
},
|
||||
}),
|
||||
]
|
||||
: [new Paragraph({ text: "" })],
|
||||
};
|
||||
});
|
||||
|
||||
// 문서 생성
|
||||
const doc = new Document({
|
||||
sections: [
|
||||
{
|
||||
properties: {},
|
||||
children: paragraphs,
|
||||
},
|
||||
],
|
||||
sections,
|
||||
});
|
||||
|
||||
// Blob 생성 및 다운로드
|
||||
|
|
|
|||
|
|
@ -1,10 +1,23 @@
|
|||
"use client";
|
||||
|
||||
import { createContext, useContext, useState, useCallback, ReactNode, useEffect, useRef } from "react";
|
||||
import { ComponentConfig, ReportDetail, ReportLayout, ReportPage, ReportLayoutConfig } from "@/types/report";
|
||||
import {
|
||||
ComponentConfig,
|
||||
ReportDetail,
|
||||
ReportLayout,
|
||||
ReportPage,
|
||||
ReportLayoutConfig,
|
||||
GridConfig,
|
||||
} from "@/types/report";
|
||||
import { reportApi } from "@/lib/api/reportApi";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import {
|
||||
snapComponentToGrid,
|
||||
createDefaultGridConfig,
|
||||
calculateGridDimensions,
|
||||
detectGridCollision,
|
||||
} from "@/lib/utils/gridUtils";
|
||||
|
||||
export interface ReportQuery {
|
||||
id: string;
|
||||
|
|
@ -71,6 +84,10 @@ interface ReportDesignerContextType {
|
|||
// 템플릿 적용
|
||||
applyTemplate: (templateId: string) => void;
|
||||
|
||||
// 그리드 관리
|
||||
gridConfig: GridConfig;
|
||||
updateGridConfig: (updates: Partial<GridConfig>) => void;
|
||||
|
||||
// 캔버스 설정
|
||||
canvasWidth: number;
|
||||
canvasHeight: number;
|
||||
|
|
@ -209,10 +226,50 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
|||
[], // ref를 사용하므로 의존성 배열 비움
|
||||
);
|
||||
|
||||
// 레이아웃 도구 설정
|
||||
const [gridSize, setGridSize] = useState(10); // Grid Snap 크기 (px)
|
||||
const [showGrid, setShowGrid] = useState(true); // Grid 표시 여부
|
||||
const [snapToGrid, setSnapToGrid] = useState(true); // Grid Snap 활성화
|
||||
// 그리드 설정
|
||||
const [gridConfig, setGridConfig] = useState<GridConfig>(() => {
|
||||
// 기본 페이지 크기 (A4: 794 x 1123 px at 96 DPI)
|
||||
const defaultPageWidth = 794;
|
||||
const defaultPageHeight = 1123;
|
||||
return createDefaultGridConfig(defaultPageWidth, defaultPageHeight);
|
||||
});
|
||||
|
||||
// gridConfig 업데이트 함수
|
||||
const updateGridConfig = useCallback(
|
||||
(updates: Partial<GridConfig>) => {
|
||||
setGridConfig((prev) => {
|
||||
const newConfig = { ...prev, ...updates };
|
||||
|
||||
// cellWidth나 cellHeight가 변경되면 rows/columns 재계산
|
||||
if (updates.cellWidth || updates.cellHeight) {
|
||||
const pageWidth = currentPage?.width ? currentPage.width * 3.7795275591 : 794; // mm to px
|
||||
const pageHeight = currentPage?.height ? currentPage.height * 3.7795275591 : 1123;
|
||||
const { rows, columns } = calculateGridDimensions(
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
newConfig.cellWidth,
|
||||
newConfig.cellHeight,
|
||||
);
|
||||
newConfig.rows = rows;
|
||||
newConfig.columns = columns;
|
||||
}
|
||||
|
||||
return newConfig;
|
||||
});
|
||||
},
|
||||
[currentPage],
|
||||
);
|
||||
|
||||
// 레거시 호환성을 위한 별칭
|
||||
const gridSize = gridConfig.cellWidth;
|
||||
const showGrid = gridConfig.visible;
|
||||
const snapToGrid = gridConfig.snapToGrid;
|
||||
const setGridSize = useCallback(
|
||||
(size: number) => updateGridConfig({ cellWidth: size, cellHeight: size }),
|
||||
[updateGridConfig],
|
||||
);
|
||||
const setShowGrid = useCallback((visible: boolean) => updateGridConfig({ visible }), [updateGridConfig]);
|
||||
const setSnapToGrid = useCallback((snap: boolean) => updateGridConfig({ snapToGrid: snap }), [updateGridConfig]);
|
||||
|
||||
// 눈금자 표시
|
||||
const [showRuler, setShowRuler] = useState(true);
|
||||
|
|
@ -1178,9 +1235,23 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
|||
// 컴포넌트 추가 (현재 페이지에)
|
||||
const addComponent = useCallback(
|
||||
(component: ComponentConfig) => {
|
||||
setComponents((prev) => [...prev, component]);
|
||||
// 그리드 스냅 적용
|
||||
const snappedComponent = snapComponentToGrid(component, gridConfig);
|
||||
|
||||
// 충돌 감지
|
||||
const currentComponents = currentPage?.components || [];
|
||||
if (detectGridCollision(snappedComponent, currentComponents, gridConfig)) {
|
||||
toast({
|
||||
title: "경고",
|
||||
description: "다른 컴포넌트와 겹칩니다. 다른 위치에 배치해주세요.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setComponents((prev) => [...prev, snappedComponent]);
|
||||
},
|
||||
[setComponents],
|
||||
[setComponents, gridConfig, currentPage, toast],
|
||||
);
|
||||
|
||||
// 컴포넌트 업데이트 (현재 페이지에서)
|
||||
|
|
@ -1188,18 +1259,60 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
|||
(id: string, updates: Partial<ComponentConfig>) => {
|
||||
if (!currentPageId) return;
|
||||
|
||||
setLayoutConfig((prev) => ({
|
||||
pages: prev.pages.map((page) =>
|
||||
page.page_id === currentPageId
|
||||
? {
|
||||
...page,
|
||||
components: page.components.map((comp) => (comp.id === id ? { ...comp, ...updates } : comp)),
|
||||
setLayoutConfig((prev) => {
|
||||
let hasCollision = false;
|
||||
|
||||
const newPages = prev.pages.map((page) => {
|
||||
if (page.page_id !== currentPageId) return page;
|
||||
|
||||
const newComponents = page.components.map((comp) => {
|
||||
if (comp.id !== id) return comp;
|
||||
|
||||
// 업데이트된 컴포넌트에 그리드 스냅 적용
|
||||
const updated = { ...comp, ...updates };
|
||||
|
||||
// 위치나 크기가 변경된 경우에만 스냅 적용 및 충돌 감지
|
||||
if (
|
||||
updates.x !== undefined ||
|
||||
updates.y !== undefined ||
|
||||
updates.width !== undefined ||
|
||||
updates.height !== undefined
|
||||
) {
|
||||
const snapped = snapComponentToGrid(updated, gridConfig);
|
||||
|
||||
// 충돌 감지 (자신을 제외한 다른 컴포넌트와)
|
||||
const otherComponents = page.components.filter((c) => c.id !== id);
|
||||
if (detectGridCollision(snapped, otherComponents, gridConfig)) {
|
||||
hasCollision = true;
|
||||
return comp; // 충돌 시 원래 상태 유지
|
||||
}
|
||||
: page,
|
||||
),
|
||||
}));
|
||||
|
||||
return snapped;
|
||||
}
|
||||
|
||||
return updated;
|
||||
});
|
||||
|
||||
return {
|
||||
...page,
|
||||
components: newComponents,
|
||||
};
|
||||
});
|
||||
|
||||
// 충돌이 감지된 경우 토스트 메시지 표시 및 업데이트 취소
|
||||
if (hasCollision) {
|
||||
toast({
|
||||
title: "경고",
|
||||
description: "다른 컴포넌트와 겹칩니다.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return prev;
|
||||
}
|
||||
|
||||
return { pages: newPages };
|
||||
});
|
||||
},
|
||||
[currentPageId],
|
||||
[currentPageId, gridConfig, toast],
|
||||
);
|
||||
|
||||
// 컴포넌트 삭제 (현재 페이지에서)
|
||||
|
|
@ -1541,6 +1654,9 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
|||
// 그룹화
|
||||
groupComponents,
|
||||
ungroupComponents,
|
||||
// 그리드 관리
|
||||
gridConfig,
|
||||
updateGridConfig,
|
||||
};
|
||||
|
||||
return <ReportDesignerContext.Provider value={value}>{children}</ReportDesignerContext.Provider>;
|
||||
|
|
|
|||
|
|
@ -1,389 +1,155 @@
|
|||
import { Position, Size } from "@/types/screen";
|
||||
import { GridSettings } from "@/types/screen-management";
|
||||
import type { ComponentConfig, GridConfig } from "@/types/report";
|
||||
|
||||
export interface GridInfo {
|
||||
columnWidth: number;
|
||||
totalWidth: number;
|
||||
totalHeight: number;
|
||||
/**
|
||||
* 픽셀 좌표를 그리드 좌표로 변환
|
||||
*/
|
||||
export function pixelToGrid(pixel: number, cellSize: number): number {
|
||||
return Math.round(pixel / cellSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* 격자 정보 계산
|
||||
* 그리드 좌표를 픽셀 좌표로 변환
|
||||
*/
|
||||
export function calculateGridInfo(
|
||||
containerWidth: number,
|
||||
containerHeight: number,
|
||||
gridSettings: GridSettings,
|
||||
): GridInfo {
|
||||
const { columns, gap, padding } = gridSettings;
|
||||
export function gridToPixel(grid: number, cellSize: number): number {
|
||||
return grid * cellSize;
|
||||
}
|
||||
|
||||
// 사용 가능한 너비 계산 (패딩 제외)
|
||||
const availableWidth = containerWidth - padding * 2;
|
||||
/**
|
||||
* 컴포넌트 위치/크기를 그리드에 스냅
|
||||
*/
|
||||
export function snapComponentToGrid(component: ComponentConfig, gridConfig: GridConfig): ComponentConfig {
|
||||
if (!gridConfig.snapToGrid) {
|
||||
return component;
|
||||
}
|
||||
|
||||
// 격자 간격을 고려한 컬럼 너비 계산
|
||||
const totalGaps = (columns - 1) * gap;
|
||||
const columnWidth = (availableWidth - totalGaps) / columns;
|
||||
// 픽셀 좌표를 그리드 좌표로 변환
|
||||
const gridX = pixelToGrid(component.x, gridConfig.cellWidth);
|
||||
const gridY = pixelToGrid(component.y, gridConfig.cellHeight);
|
||||
const gridWidth = Math.max(1, pixelToGrid(component.width, gridConfig.cellWidth));
|
||||
const gridHeight = Math.max(1, pixelToGrid(component.height, gridConfig.cellHeight));
|
||||
|
||||
// 그리드 좌표를 다시 픽셀로 변환
|
||||
return {
|
||||
columnWidth: Math.max(columnWidth, 20), // 최소 20px로 줄여서 더 많은 컬럼 표시
|
||||
totalWidth: containerWidth,
|
||||
totalHeight: containerHeight,
|
||||
...component,
|
||||
gridX,
|
||||
gridY,
|
||||
gridWidth,
|
||||
gridHeight,
|
||||
x: gridToPixel(gridX, gridConfig.cellWidth),
|
||||
y: gridToPixel(gridY, gridConfig.cellHeight),
|
||||
width: gridToPixel(gridWidth, gridConfig.cellWidth),
|
||||
height: gridToPixel(gridHeight, gridConfig.cellHeight),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 위치를 격자에 맞춤
|
||||
* 그리드 충돌 감지
|
||||
* 두 컴포넌트가 겹치는지 확인
|
||||
*/
|
||||
export function snapToGrid(position: Position, gridInfo: GridInfo, gridSettings: GridSettings): Position {
|
||||
if (!gridSettings.snapToGrid) {
|
||||
return position;
|
||||
}
|
||||
|
||||
const { columnWidth } = gridInfo;
|
||||
const { gap, padding } = gridSettings;
|
||||
|
||||
// 격자 셀 크기 (컬럼 너비 + 간격을 하나의 격자 단위로 계산)
|
||||
const cellWidth = columnWidth + gap;
|
||||
const cellHeight = Math.max(40, gap * 2); // 행 높이를 더 크게 설정
|
||||
|
||||
// 패딩을 제외한 상대 위치
|
||||
const relativeX = position.x - padding;
|
||||
const relativeY = position.y - padding;
|
||||
|
||||
// 격자 기준으로 위치 계산 (가장 가까운 격자점으로 스냅)
|
||||
const gridX = Math.round(relativeX / cellWidth);
|
||||
const gridY = Math.round(relativeY / cellHeight);
|
||||
|
||||
// 실제 픽셀 위치로 변환
|
||||
const snappedX = Math.max(padding, padding + gridX * cellWidth);
|
||||
const snappedY = Math.max(padding, padding + gridY * cellHeight);
|
||||
|
||||
return {
|
||||
x: snappedX,
|
||||
y: snappedY,
|
||||
z: position.z,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 크기를 격자에 맞춤
|
||||
*/
|
||||
export function snapSizeToGrid(size: Size, gridInfo: GridInfo, gridSettings: GridSettings): Size {
|
||||
if (!gridSettings.snapToGrid) {
|
||||
return size;
|
||||
}
|
||||
|
||||
const { columnWidth } = gridInfo;
|
||||
const { gap } = gridSettings;
|
||||
|
||||
// 격자 단위로 너비 계산
|
||||
// 컴포넌트가 차지하는 컬럼 수를 올바르게 계산
|
||||
let gridColumns = 1;
|
||||
|
||||
// 현재 너비에서 가장 가까운 격자 컬럼 수 찾기
|
||||
for (let cols = 1; cols <= gridSettings.columns; cols++) {
|
||||
const targetWidth = cols * columnWidth + (cols - 1) * gap;
|
||||
if (size.width <= targetWidth + (columnWidth + gap) / 2) {
|
||||
gridColumns = cols;
|
||||
break;
|
||||
}
|
||||
gridColumns = cols;
|
||||
}
|
||||
|
||||
const snappedWidth = gridColumns * columnWidth + (gridColumns - 1) * gap;
|
||||
|
||||
// 높이는 동적 행 높이 단위로 스냅
|
||||
const rowHeight = Math.max(20, gap);
|
||||
const snappedHeight = Math.max(40, Math.round(size.height / rowHeight) * rowHeight);
|
||||
|
||||
console.log(
|
||||
`📏 크기 스냅: ${size.width}px → ${snappedWidth}px (${gridColumns}컬럼, 컬럼너비:${columnWidth}px, 간격:${gap}px)`,
|
||||
);
|
||||
|
||||
return {
|
||||
width: Math.max(columnWidth, snappedWidth),
|
||||
height: snappedHeight,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 격자 컬럼 수로 너비 계산
|
||||
*/
|
||||
export function calculateWidthFromColumns(columns: number, gridInfo: GridInfo, gridSettings: GridSettings): number {
|
||||
const { columnWidth } = gridInfo;
|
||||
const { gap } = gridSettings;
|
||||
|
||||
return columns * columnWidth + (columns - 1) * gap;
|
||||
}
|
||||
|
||||
/**
|
||||
* gridColumns 속성을 기반으로 컴포넌트 크기 업데이트
|
||||
*/
|
||||
export function updateSizeFromGridColumns(
|
||||
component: { gridColumns?: number; size: Size },
|
||||
gridInfo: GridInfo,
|
||||
gridSettings: GridSettings,
|
||||
): Size {
|
||||
if (!component.gridColumns || component.gridColumns < 1) {
|
||||
return component.size;
|
||||
}
|
||||
|
||||
const newWidth = calculateWidthFromColumns(component.gridColumns, gridInfo, gridSettings);
|
||||
|
||||
return {
|
||||
width: newWidth,
|
||||
height: component.size.height, // 높이는 유지
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 컴포넌트의 gridColumns를 자동으로 크기에 맞게 조정
|
||||
*/
|
||||
export function adjustGridColumnsFromSize(
|
||||
component: { size: Size },
|
||||
gridInfo: GridInfo,
|
||||
gridSettings: GridSettings,
|
||||
): number {
|
||||
const columns = calculateColumnsFromWidth(component.size.width, gridInfo, gridSettings);
|
||||
return Math.min(Math.max(1, columns), gridSettings.columns); // 1-12 범위로 제한
|
||||
}
|
||||
|
||||
/**
|
||||
* 너비에서 격자 컬럼 수 계산
|
||||
*/
|
||||
export function calculateColumnsFromWidth(width: number, gridInfo: GridInfo, gridSettings: GridSettings): number {
|
||||
const { columnWidth } = gridInfo;
|
||||
const { gap } = gridSettings;
|
||||
|
||||
return Math.max(1, Math.round((width + gap) / (columnWidth + gap)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 격자 가이드라인 생성
|
||||
*/
|
||||
export function generateGridLines(
|
||||
containerWidth: number,
|
||||
containerHeight: number,
|
||||
gridSettings: GridSettings,
|
||||
): {
|
||||
verticalLines: number[];
|
||||
horizontalLines: number[];
|
||||
} {
|
||||
const { columns, gap, padding } = gridSettings;
|
||||
const gridInfo = calculateGridInfo(containerWidth, containerHeight, gridSettings);
|
||||
const { columnWidth } = gridInfo;
|
||||
|
||||
// 격자 셀 크기 (스냅 로직과 동일하게)
|
||||
const cellWidth = columnWidth + gap;
|
||||
const cellHeight = Math.max(40, gap * 2);
|
||||
|
||||
// 세로 격자선
|
||||
const verticalLines: number[] = [];
|
||||
for (let i = 0; i <= columns; i++) {
|
||||
const x = padding + i * cellWidth;
|
||||
if (x <= containerWidth) {
|
||||
verticalLines.push(x);
|
||||
}
|
||||
}
|
||||
|
||||
// 가로 격자선
|
||||
const horizontalLines: number[] = [];
|
||||
for (let y = padding; y < containerHeight; y += cellHeight) {
|
||||
horizontalLines.push(y);
|
||||
}
|
||||
|
||||
return {
|
||||
verticalLines,
|
||||
horizontalLines,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 컴포넌트가 격자 경계에 있는지 확인
|
||||
*/
|
||||
export function isOnGridBoundary(
|
||||
position: Position,
|
||||
size: Size,
|
||||
gridInfo: GridInfo,
|
||||
gridSettings: GridSettings,
|
||||
tolerance: number = 5,
|
||||
export function detectGridCollision(
|
||||
component: ComponentConfig,
|
||||
otherComponents: ComponentConfig[],
|
||||
gridConfig: GridConfig,
|
||||
): boolean {
|
||||
const snappedPos = snapToGrid(position, gridInfo, gridSettings);
|
||||
const snappedSize = snapSizeToGrid(size, gridInfo, gridSettings);
|
||||
const comp1GridX = component.gridX ?? pixelToGrid(component.x, gridConfig.cellWidth);
|
||||
const comp1GridY = component.gridY ?? pixelToGrid(component.y, gridConfig.cellHeight);
|
||||
const comp1GridWidth = component.gridWidth ?? pixelToGrid(component.width, gridConfig.cellWidth);
|
||||
const comp1GridHeight = component.gridHeight ?? pixelToGrid(component.height, gridConfig.cellHeight);
|
||||
|
||||
const positionMatch =
|
||||
Math.abs(position.x - snappedPos.x) <= tolerance && Math.abs(position.y - snappedPos.y) <= tolerance;
|
||||
for (const other of otherComponents) {
|
||||
if (other.id === component.id) continue;
|
||||
|
||||
const sizeMatch =
|
||||
Math.abs(size.width - snappedSize.width) <= tolerance && Math.abs(size.height - snappedSize.height) <= tolerance;
|
||||
const comp2GridX = other.gridX ?? pixelToGrid(other.x, gridConfig.cellWidth);
|
||||
const comp2GridY = other.gridY ?? pixelToGrid(other.y, gridConfig.cellHeight);
|
||||
const comp2GridWidth = other.gridWidth ?? pixelToGrid(other.width, gridConfig.cellWidth);
|
||||
const comp2GridHeight = other.gridHeight ?? pixelToGrid(other.height, gridConfig.cellHeight);
|
||||
|
||||
return positionMatch && sizeMatch;
|
||||
// AABB (Axis-Aligned Bounding Box) 충돌 감지
|
||||
const xOverlap = comp1GridX < comp2GridX + comp2GridWidth && comp1GridX + comp1GridWidth > comp2GridX;
|
||||
const yOverlap = comp1GridY < comp2GridY + comp2GridHeight && comp1GridY + comp1GridHeight > comp2GridY;
|
||||
|
||||
if (xOverlap && yOverlap) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 그룹 내부 컴포넌트들을 격자에 맞게 정렬
|
||||
* 페이지 크기 기반 그리드 행/열 계산
|
||||
*/
|
||||
export function alignGroupChildrenToGrid(
|
||||
children: any[],
|
||||
groupPosition: Position,
|
||||
gridInfo: GridInfo,
|
||||
gridSettings: GridSettings,
|
||||
): any[] {
|
||||
if (!gridSettings.snapToGrid || children.length === 0) return children;
|
||||
|
||||
console.log("🔧 alignGroupChildrenToGrid 시작:", {
|
||||
childrenCount: children.length,
|
||||
groupPosition,
|
||||
gridInfo,
|
||||
gridSettings,
|
||||
});
|
||||
|
||||
return children.map((child, index) => {
|
||||
console.log(`📐 자식 ${index + 1} 처리 중:`, {
|
||||
childId: child.id,
|
||||
originalPosition: child.position,
|
||||
originalSize: child.size,
|
||||
});
|
||||
|
||||
const { columnWidth } = gridInfo;
|
||||
const { gap } = gridSettings;
|
||||
|
||||
// 그룹 내부 패딩 고려한 격자 정렬
|
||||
const padding = 16;
|
||||
const effectiveX = child.position.x - padding;
|
||||
const columnIndex = Math.round(effectiveX / (columnWidth + gap));
|
||||
const snappedX = padding + columnIndex * (columnWidth + gap);
|
||||
|
||||
// Y 좌표는 동적 행 높이 단위로 스냅
|
||||
const rowHeight = Math.max(20, gap);
|
||||
const effectiveY = child.position.y - padding;
|
||||
const rowIndex = Math.round(effectiveY / rowHeight);
|
||||
const snappedY = padding + rowIndex * rowHeight;
|
||||
|
||||
// 크기는 외부 격자와 동일하게 스냅 (columnWidth + gap 사용)
|
||||
const fullColumnWidth = columnWidth + gap; // 외부 격자와 동일한 크기
|
||||
const widthInColumns = Math.max(1, Math.round(child.size.width / fullColumnWidth));
|
||||
const snappedWidth = widthInColumns * fullColumnWidth - gap; // gap 제거하여 실제 컴포넌트 크기
|
||||
const snappedHeight = Math.max(40, Math.round(child.size.height / rowHeight) * rowHeight);
|
||||
|
||||
const snappedChild = {
|
||||
...child,
|
||||
position: {
|
||||
x: Math.max(padding, snappedX), // 패딩만큼 최소 여백 확보
|
||||
y: Math.max(padding, snappedY),
|
||||
z: child.position.z || 1,
|
||||
},
|
||||
size: {
|
||||
width: snappedWidth,
|
||||
height: snappedHeight,
|
||||
},
|
||||
export function calculateGridDimensions(
|
||||
pageWidth: number,
|
||||
pageHeight: number,
|
||||
cellWidth: number,
|
||||
cellHeight: number,
|
||||
): { rows: number; columns: number } {
|
||||
return {
|
||||
columns: Math.floor(pageWidth / cellWidth),
|
||||
rows: Math.floor(pageHeight / cellHeight),
|
||||
};
|
||||
|
||||
console.log(`✅ 자식 ${index + 1} 격자 정렬 완료:`, {
|
||||
childId: child.id,
|
||||
calculation: {
|
||||
effectiveX,
|
||||
effectiveY,
|
||||
columnIndex,
|
||||
rowIndex,
|
||||
widthInColumns,
|
||||
originalX: child.position.x,
|
||||
snappedX: snappedChild.position.x,
|
||||
padding,
|
||||
},
|
||||
snappedPosition: snappedChild.position,
|
||||
snappedSize: snappedChild.size,
|
||||
deltaX: snappedChild.position.x - child.position.x,
|
||||
deltaY: snappedChild.position.y - child.position.y,
|
||||
});
|
||||
|
||||
return snappedChild;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 그룹 생성 시 최적화된 그룹 크기 계산
|
||||
* 기본 그리드 설정 생성
|
||||
*/
|
||||
export function calculateOptimalGroupSize(
|
||||
children: Array<{ position: Position; size: Size }>,
|
||||
gridInfo: GridInfo,
|
||||
gridSettings: GridSettings,
|
||||
): Size {
|
||||
if (children.length === 0) {
|
||||
return { width: gridInfo.columnWidth * 2, height: 40 * 2 };
|
||||
export function createDefaultGridConfig(pageWidth: number, pageHeight: number): GridConfig {
|
||||
const cellWidth = 20;
|
||||
const cellHeight = 20;
|
||||
const { rows, columns } = calculateGridDimensions(pageWidth, pageHeight, cellWidth, cellHeight);
|
||||
|
||||
return {
|
||||
cellWidth,
|
||||
cellHeight,
|
||||
rows,
|
||||
columns,
|
||||
visible: true,
|
||||
snapToGrid: true,
|
||||
gridColor: "#e5e7eb",
|
||||
gridOpacity: 0.5,
|
||||
};
|
||||
}
|
||||
|
||||
console.log("📏 calculateOptimalGroupSize 시작:", {
|
||||
childrenCount: children.length,
|
||||
children: children.map((c) => ({ pos: c.position, size: c.size })),
|
||||
});
|
||||
/**
|
||||
* 위치가 페이지 경계 내에 있는지 확인
|
||||
*/
|
||||
export function isWithinPageBounds(
|
||||
component: ComponentConfig,
|
||||
pageWidth: number,
|
||||
pageHeight: number,
|
||||
margins: { top: number; bottom: number; left: number; right: number },
|
||||
): boolean {
|
||||
const minX = margins.left;
|
||||
const minY = margins.top;
|
||||
const maxX = pageWidth - margins.right;
|
||||
const maxY = pageHeight - margins.bottom;
|
||||
|
||||
// 모든 자식 컴포넌트를 포함하는 최소 경계 계산
|
||||
const bounds = children.reduce(
|
||||
(acc, child) => ({
|
||||
minX: Math.min(acc.minX, child.position.x),
|
||||
minY: Math.min(acc.minY, child.position.y),
|
||||
maxX: Math.max(acc.maxX, child.position.x + child.size.width),
|
||||
maxY: Math.max(acc.maxY, child.position.y + child.size.height),
|
||||
}),
|
||||
{ minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
|
||||
return (
|
||||
component.x >= minX &&
|
||||
component.y >= minY &&
|
||||
component.x + component.width <= maxX &&
|
||||
component.y + component.height <= maxY
|
||||
);
|
||||
|
||||
console.log("📐 경계 계산:", bounds);
|
||||
|
||||
const contentWidth = bounds.maxX - bounds.minX;
|
||||
const contentHeight = bounds.maxY - bounds.minY;
|
||||
|
||||
// 그룹은 격자 스냅 없이 컨텐츠에 맞는 자연스러운 크기
|
||||
const padding = 16; // 그룹 내부 여백
|
||||
const groupSize = {
|
||||
width: contentWidth + padding * 2,
|
||||
height: contentHeight + padding * 2,
|
||||
};
|
||||
|
||||
console.log("✅ 자연스러운 그룹 크기:", {
|
||||
contentSize: { width: contentWidth, height: contentHeight },
|
||||
withPadding: groupSize,
|
||||
strategy: "그룹은 격자 스냅 없이, 내부 컴포넌트만 격자에 맞춤",
|
||||
});
|
||||
|
||||
return groupSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* 그룹 내 상대 좌표를 격자 기준으로 정규화
|
||||
* 컴포넌트를 페이지 경계 내로 제한
|
||||
*/
|
||||
export function normalizeGroupChildPositions(children: any[], gridSettings: GridSettings): any[] {
|
||||
if (!gridSettings.snapToGrid || children.length === 0) return children;
|
||||
export function constrainToPageBounds(
|
||||
component: ComponentConfig,
|
||||
pageWidth: number,
|
||||
pageHeight: number,
|
||||
margins: { top: number; bottom: number; left: number; right: number },
|
||||
): ComponentConfig {
|
||||
const minX = margins.left;
|
||||
const minY = margins.top;
|
||||
const maxX = pageWidth - margins.right - component.width;
|
||||
const maxY = pageHeight - margins.bottom - component.height;
|
||||
|
||||
console.log("🔄 normalizeGroupChildPositions 시작:", {
|
||||
childrenCount: children.length,
|
||||
originalPositions: children.map((c) => ({ id: c.id, pos: c.position })),
|
||||
});
|
||||
|
||||
// 모든 자식의 최소 위치 찾기
|
||||
const minX = Math.min(...children.map((child) => child.position.x));
|
||||
const minY = Math.min(...children.map((child) => child.position.y));
|
||||
|
||||
console.log("📍 최소 위치:", { minX, minY });
|
||||
|
||||
// 그룹 내에서 시작점을 패딩만큼 떨어뜨림 (자연스러운 여백)
|
||||
const padding = 16;
|
||||
const startX = padding;
|
||||
const startY = padding;
|
||||
|
||||
const normalizedChildren = children.map((child) => ({
|
||||
...child,
|
||||
position: {
|
||||
x: child.position.x - minX + startX,
|
||||
y: child.position.y - minY + startY,
|
||||
z: child.position.z || 1,
|
||||
},
|
||||
}));
|
||||
|
||||
console.log("✅ 정규화 완료:", {
|
||||
normalizedPositions: normalizedChildren.map((c) => ({ id: c.id, pos: c.position })),
|
||||
});
|
||||
|
||||
return normalizedChildren;
|
||||
return {
|
||||
...component,
|
||||
x: Math.max(minX, Math.min(maxX, component.x)),
|
||||
y: Math.max(minY, Math.min(maxY, component.y)),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,6 +81,18 @@ export interface ExternalConnection {
|
|||
is_active: string;
|
||||
}
|
||||
|
||||
// 그리드 설정
|
||||
export interface GridConfig {
|
||||
cellWidth: number; // 그리드 셀 너비 (px)
|
||||
cellHeight: number; // 그리드 셀 높이 (px)
|
||||
rows: number; // 세로 그리드 수 (계산값: pageHeight / cellHeight)
|
||||
columns: number; // 가로 그리드 수 (계산값: pageWidth / cellHeight)
|
||||
visible: boolean; // 그리드 표시 여부
|
||||
snapToGrid: boolean; // 그리드 스냅 활성화 여부
|
||||
gridColor: string; // 그리드 선 색상
|
||||
gridOpacity: number; // 그리드 투명도 (0-1)
|
||||
}
|
||||
|
||||
// 페이지 설정
|
||||
export interface ReportPage {
|
||||
page_id: string;
|
||||
|
|
@ -96,6 +108,7 @@ export interface ReportPage {
|
|||
right: number;
|
||||
};
|
||||
background_color: string;
|
||||
gridConfig?: GridConfig; // 그리드 설정 (옵셔널)
|
||||
components: ComponentConfig[];
|
||||
}
|
||||
|
||||
|
|
@ -113,6 +126,11 @@ export interface ComponentConfig {
|
|||
width: number;
|
||||
height: number;
|
||||
zIndex: number;
|
||||
// 그리드 좌표 (옵셔널)
|
||||
gridX?: number; // 시작 열 (0부터 시작)
|
||||
gridY?: number; // 시작 행 (0부터 시작)
|
||||
gridWidth?: number; // 차지하는 열 수
|
||||
gridHeight?: number; // 차지하는 행 수
|
||||
fontSize?: number;
|
||||
fontFamily?: string;
|
||||
fontWeight?: string;
|
||||
|
|
|
|||
Loading…
Reference in New Issue