레포트관리에 그리드 시스템 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 { ComponentConfig } from "@/types/report";
|
||||||
import { CanvasComponent } from "./CanvasComponent";
|
import { CanvasComponent } from "./CanvasComponent";
|
||||||
import { Ruler } from "./Ruler";
|
import { Ruler } from "./Ruler";
|
||||||
|
import { GridLayer } from "./GridLayer";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
|
||||||
export function ReportDesignerCanvas() {
|
export function ReportDesignerCanvas() {
|
||||||
|
|
@ -32,6 +33,7 @@ export function ReportDesignerCanvas() {
|
||||||
undo,
|
undo,
|
||||||
redo,
|
redo,
|
||||||
showRuler,
|
showRuler,
|
||||||
|
gridConfig,
|
||||||
} = useReportDesigner();
|
} = useReportDesigner();
|
||||||
|
|
||||||
const [{ isOver }, drop] = useDrop(() => ({
|
const [{ isOver }, drop] = useDrop(() => ({
|
||||||
|
|
@ -331,16 +333,16 @@ export function ReportDesignerCanvas() {
|
||||||
style={{
|
style={{
|
||||||
width: `${canvasWidth}mm`,
|
width: `${canvasWidth}mm`,
|
||||||
minHeight: `${canvasHeight}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}
|
onClick={handleCanvasClick}
|
||||||
>
|
>
|
||||||
|
{/* 그리드 레이어 */}
|
||||||
|
<GridLayer
|
||||||
|
gridConfig={gridConfig}
|
||||||
|
pageWidth={canvasWidth * 3.7795} // mm to px
|
||||||
|
pageHeight={canvasHeight * 3.7795}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 페이지 여백 가이드 */}
|
{/* 페이지 여백 가이드 */}
|
||||||
{currentPage && (
|
{currentPage && (
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||||
import { QueryManager } from "./QueryManager";
|
import { QueryManager } from "./QueryManager";
|
||||||
import { SignaturePad } from "./SignaturePad";
|
import { SignaturePad } from "./SignaturePad";
|
||||||
import { SignatureGenerator } from "./SignatureGenerator";
|
import { SignatureGenerator } from "./SignatureGenerator";
|
||||||
|
import { GridSettingsPanel } from "./GridSettingsPanel";
|
||||||
import { reportApi } from "@/lib/api/reportApi";
|
import { reportApi } from "@/lib/api/reportApi";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
|
|
@ -102,7 +103,7 @@ export function ReportDesignerRightPanel() {
|
||||||
<div className="w-[450px] border-l bg-white">
|
<div className="w-[450px] border-l bg-white">
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="h-full">
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="h-full">
|
||||||
<div className="border-b p-2">
|
<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">
|
<TabsTrigger value="page" className="gap-1 text-xs">
|
||||||
<Settings className="h-3 w-3" />
|
<Settings className="h-3 w-3" />
|
||||||
페이지
|
페이지
|
||||||
|
|
@ -111,6 +112,10 @@ export function ReportDesignerRightPanel() {
|
||||||
<Settings className="h-3 w-3" />
|
<Settings className="h-3 w-3" />
|
||||||
속성
|
속성
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="grid" className="gap-1 text-xs">
|
||||||
|
<Settings className="h-3 w-3" />
|
||||||
|
그리드
|
||||||
|
</TabsTrigger>
|
||||||
<TabsTrigger value="queries" className="gap-1 text-xs">
|
<TabsTrigger value="queries" className="gap-1 text-xs">
|
||||||
<Database className="h-3 w-3" />
|
<Database className="h-3 w-3" />
|
||||||
쿼리
|
쿼리
|
||||||
|
|
@ -1396,6 +1401,15 @@ export function ReportDesignerRightPanel() {
|
||||||
</TabsContent>
|
</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)]">
|
<TabsContent value="queries" className="mt-0 h-[calc(100vh-120px)]">
|
||||||
<QueryManager />
|
<QueryManager />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,21 @@ import { Printer, FileDown, FileText } from "lucide-react";
|
||||||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
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";
|
import { getFullImageUrl } from "@/lib/api/client";
|
||||||
|
|
||||||
interface ReportPreviewModalProps {
|
interface ReportPreviewModalProps {
|
||||||
|
|
@ -268,82 +282,263 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 컴포넌트를 TableCell로 변환
|
||||||
|
const createTableCell = (component: any, pageWidth: number, widthPercent?: number): TableCell | null => {
|
||||||
|
const cellWidth = widthPercent || 100;
|
||||||
|
|
||||||
|
if (component.type === "text" || component.type === "label") {
|
||||||
|
const value = getComponentValue(component);
|
||||||
|
return new TableCell({
|
||||||
|
children: [
|
||||||
|
new Paragraph({
|
||||||
|
children: [
|
||||||
|
new TextRun({
|
||||||
|
text: value,
|
||||||
|
size: (component.fontSize || 13) * 2,
|
||||||
|
color: component.fontColor?.replace("#", "") || "000000",
|
||||||
|
bold: component.fontWeight === "bold",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
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({
|
||||||
|
children: [new Paragraph({ text: field })],
|
||||||
|
width: { size: 100 / queryResult.fields.length, type: WidthType.PERCENTAGE },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const dataRows = queryResult.rows.map(
|
||||||
|
(row) =>
|
||||||
|
new TableRow({
|
||||||
|
children: queryResult.fields.map(
|
||||||
|
(field) =>
|
||||||
|
new TableCell({
|
||||||
|
children: [new Paragraph({ text: String(row[field] ?? "") })],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const table = new Table({
|
||||||
|
rows: [new TableRow({ children: headerCells }), ...dataRows],
|
||||||
|
width: { size: 100, type: WidthType.PERCENTAGE },
|
||||||
|
});
|
||||||
|
|
||||||
|
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 다운로드
|
// WORD 다운로드
|
||||||
const handleDownloadWord = async () => {
|
const handleDownloadWord = async () => {
|
||||||
setIsExporting(true);
|
setIsExporting(true);
|
||||||
try {
|
try {
|
||||||
// 컴포넌트를 Paragraph로 변환
|
// 페이지별로 섹션 생성
|
||||||
const paragraphs: (Paragraph | Table)[] = [];
|
const sections = layoutConfig.pages
|
||||||
|
|
||||||
// 모든 페이지의 컴포넌트 수집
|
|
||||||
const allComponents = layoutConfig.pages
|
|
||||||
.sort((a, b) => a.page_order - b.page_order)
|
.sort((a, b) => a.page_order - b.page_order)
|
||||||
.flatMap((page) => page.components);
|
.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 좌표로 정렬
|
// 페이지 내 컴포넌트를 Y좌표 기준으로 정렬
|
||||||
const sortedComponents = [...allComponents].sort((a, b) => a.y - b.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;
|
||||||
|
});
|
||||||
|
|
||||||
for (const component of sortedComponents) {
|
// 컴포넌트들을 행으로 그룹화 (같은 Y 범위 = 같은 행)
|
||||||
if (component.type === "text" || component.type === "label") {
|
const rows: Array<Array<(typeof sortedComponents)[0]>> = [];
|
||||||
const value = getComponentValue(component);
|
const rowTolerance = 20; // Y 좌표 허용 오차
|
||||||
paragraphs.push(
|
|
||||||
new Paragraph({
|
|
||||||
children: [
|
|
||||||
new TextRun({
|
|
||||||
text: value,
|
|
||||||
size: (component.fontSize || 13) * 2, // pt to half-pt
|
|
||||||
color: component.fontColor?.replace("#", "") || "000000",
|
|
||||||
bold: component.fontWeight === "bold",
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
spacing: {
|
|
||||||
after: 200,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
} 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({
|
|
||||||
children: [new Paragraph({ text: field })],
|
|
||||||
width: { size: 100 / queryResult.fields.length, type: WidthType.PERCENTAGE },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// 테이블 행
|
for (const component of sortedComponents) {
|
||||||
const dataRows = queryResult.rows.map(
|
const existingRow = rows.find((row) => Math.abs(row[0].y - component.y) < rowTolerance);
|
||||||
(row) =>
|
if (existingRow) {
|
||||||
new TableRow({
|
existingRow.push(component);
|
||||||
children: queryResult.fields.map(
|
} else {
|
||||||
(field) =>
|
rows.push([component]);
|
||||||
new TableCell({
|
}
|
||||||
children: [new Paragraph({ text: String(row[field] ?? "") })],
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const table = new Table({
|
|
||||||
rows: [new TableRow({ children: headerCells }), ...dataRows],
|
|
||||||
width: { size: 100, type: WidthType.PERCENTAGE },
|
|
||||||
});
|
|
||||||
|
|
||||||
paragraphs.push(table);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
// 각 행 내에서 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({
|
const doc = new Document({
|
||||||
sections: [
|
sections,
|
||||||
{
|
|
||||||
properties: {},
|
|
||||||
children: paragraphs,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Blob 생성 및 다운로드
|
// Blob 생성 및 다운로드
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,23 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { createContext, useContext, useState, useCallback, ReactNode, useEffect, useRef } from "react";
|
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 { reportApi } from "@/lib/api/reportApi";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
import {
|
||||||
|
snapComponentToGrid,
|
||||||
|
createDefaultGridConfig,
|
||||||
|
calculateGridDimensions,
|
||||||
|
detectGridCollision,
|
||||||
|
} from "@/lib/utils/gridUtils";
|
||||||
|
|
||||||
export interface ReportQuery {
|
export interface ReportQuery {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -71,6 +84,10 @@ interface ReportDesignerContextType {
|
||||||
// 템플릿 적용
|
// 템플릿 적용
|
||||||
applyTemplate: (templateId: string) => void;
|
applyTemplate: (templateId: string) => void;
|
||||||
|
|
||||||
|
// 그리드 관리
|
||||||
|
gridConfig: GridConfig;
|
||||||
|
updateGridConfig: (updates: Partial<GridConfig>) => void;
|
||||||
|
|
||||||
// 캔버스 설정
|
// 캔버스 설정
|
||||||
canvasWidth: number;
|
canvasWidth: number;
|
||||||
canvasHeight: number;
|
canvasHeight: number;
|
||||||
|
|
@ -209,10 +226,50 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
||||||
[], // ref를 사용하므로 의존성 배열 비움
|
[], // ref를 사용하므로 의존성 배열 비움
|
||||||
);
|
);
|
||||||
|
|
||||||
// 레이아웃 도구 설정
|
// 그리드 설정
|
||||||
const [gridSize, setGridSize] = useState(10); // Grid Snap 크기 (px)
|
const [gridConfig, setGridConfig] = useState<GridConfig>(() => {
|
||||||
const [showGrid, setShowGrid] = useState(true); // Grid 표시 여부
|
// 기본 페이지 크기 (A4: 794 x 1123 px at 96 DPI)
|
||||||
const [snapToGrid, setSnapToGrid] = useState(true); // Grid Snap 활성화
|
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);
|
const [showRuler, setShowRuler] = useState(true);
|
||||||
|
|
@ -1178,9 +1235,23 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
||||||
// 컴포넌트 추가 (현재 페이지에)
|
// 컴포넌트 추가 (현재 페이지에)
|
||||||
const addComponent = useCallback(
|
const addComponent = useCallback(
|
||||||
(component: ComponentConfig) => {
|
(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>) => {
|
(id: string, updates: Partial<ComponentConfig>) => {
|
||||||
if (!currentPageId) return;
|
if (!currentPageId) return;
|
||||||
|
|
||||||
setLayoutConfig((prev) => ({
|
setLayoutConfig((prev) => {
|
||||||
pages: prev.pages.map((page) =>
|
let hasCollision = false;
|
||||||
page.page_id === currentPageId
|
|
||||||
? {
|
const newPages = prev.pages.map((page) => {
|
||||||
...page,
|
if (page.page_id !== currentPageId) return page;
|
||||||
components: page.components.map((comp) => (comp.id === id ? { ...comp, ...updates } : comp)),
|
|
||||||
|
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,
|
groupComponents,
|
||||||
ungroupComponents,
|
ungroupComponents,
|
||||||
|
// 그리드 관리
|
||||||
|
gridConfig,
|
||||||
|
updateGridConfig,
|
||||||
};
|
};
|
||||||
|
|
||||||
return <ReportDesignerContext.Provider value={value}>{children}</ReportDesignerContext.Provider>;
|
return <ReportDesignerContext.Provider value={value}>{children}</ReportDesignerContext.Provider>;
|
||||||
|
|
|
||||||
|
|
@ -1,389 +1,155 @@
|
||||||
import { Position, Size } from "@/types/screen";
|
import type { ComponentConfig, GridConfig } from "@/types/report";
|
||||||
import { GridSettings } from "@/types/screen-management";
|
|
||||||
|
|
||||||
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(
|
export function gridToPixel(grid: number, cellSize: number): number {
|
||||||
containerWidth: number,
|
return grid * cellSize;
|
||||||
containerHeight: number,
|
}
|
||||||
gridSettings: GridSettings,
|
|
||||||
): GridInfo {
|
|
||||||
const { columns, gap, padding } = gridSettings;
|
|
||||||
|
|
||||||
// 사용 가능한 너비 계산 (패딩 제외)
|
/**
|
||||||
const availableWidth = containerWidth - padding * 2;
|
* 컴포넌트 위치/크기를 그리드에 스냅
|
||||||
|
*/
|
||||||
|
export function snapComponentToGrid(component: ComponentConfig, gridConfig: GridConfig): ComponentConfig {
|
||||||
|
if (!gridConfig.snapToGrid) {
|
||||||
|
return component;
|
||||||
|
}
|
||||||
|
|
||||||
// 격자 간격을 고려한 컬럼 너비 계산
|
// 픽셀 좌표를 그리드 좌표로 변환
|
||||||
const totalGaps = (columns - 1) * gap;
|
const gridX = pixelToGrid(component.x, gridConfig.cellWidth);
|
||||||
const columnWidth = (availableWidth - totalGaps) / columns;
|
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 {
|
return {
|
||||||
columnWidth: Math.max(columnWidth, 20), // 최소 20px로 줄여서 더 많은 컬럼 표시
|
...component,
|
||||||
totalWidth: containerWidth,
|
gridX,
|
||||||
totalHeight: containerHeight,
|
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 {
|
export function detectGridCollision(
|
||||||
if (!gridSettings.snapToGrid) {
|
component: ComponentConfig,
|
||||||
return position;
|
otherComponents: ComponentConfig[],
|
||||||
}
|
gridConfig: GridConfig,
|
||||||
|
|
||||||
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,
|
|
||||||
): boolean {
|
): boolean {
|
||||||
const snappedPos = snapToGrid(position, gridInfo, gridSettings);
|
const comp1GridX = component.gridX ?? pixelToGrid(component.x, gridConfig.cellWidth);
|
||||||
const snappedSize = snapSizeToGrid(size, gridInfo, gridSettings);
|
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 =
|
for (const other of otherComponents) {
|
||||||
Math.abs(position.x - snappedPos.x) <= tolerance && Math.abs(position.y - snappedPos.y) <= tolerance;
|
if (other.id === component.id) continue;
|
||||||
|
|
||||||
const sizeMatch =
|
const comp2GridX = other.gridX ?? pixelToGrid(other.x, gridConfig.cellWidth);
|
||||||
Math.abs(size.width - snappedSize.width) <= tolerance && Math.abs(size.height - snappedSize.height) <= tolerance;
|
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;
|
||||||
*/
|
}
|
||||||
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,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("📏 calculateOptimalGroupSize 시작:", {
|
return false;
|
||||||
childrenCount: children.length,
|
|
||||||
children: children.map((c) => ({ pos: c.position, size: c.size })),
|
|
||||||
});
|
|
||||||
|
|
||||||
// 모든 자식 컴포넌트를 포함하는 최소 경계 계산
|
|
||||||
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 },
|
|
||||||
);
|
|
||||||
|
|
||||||
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[] {
|
export function calculateGridDimensions(
|
||||||
if (!gridSettings.snapToGrid || children.length === 0) return children;
|
pageWidth: number,
|
||||||
|
pageHeight: number,
|
||||||
console.log("🔄 normalizeGroupChildPositions 시작:", {
|
cellWidth: number,
|
||||||
childrenCount: children.length,
|
cellHeight: number,
|
||||||
originalPositions: children.map((c) => ({ id: c.id, pos: c.position })),
|
): { rows: number; columns: number } {
|
||||||
});
|
return {
|
||||||
|
columns: Math.floor(pageWidth / cellWidth),
|
||||||
// 모든 자식의 최소 위치 찾기
|
rows: Math.floor(pageHeight / cellHeight),
|
||||||
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;
|
export function createDefaultGridConfig(pageWidth: number, pageHeight: number): GridConfig {
|
||||||
const startX = padding;
|
const cellWidth = 20;
|
||||||
const startY = padding;
|
const cellHeight = 20;
|
||||||
|
const { rows, columns } = calculateGridDimensions(pageWidth, pageHeight, cellWidth, cellHeight);
|
||||||
const normalizedChildren = children.map((child) => ({
|
|
||||||
...child,
|
return {
|
||||||
position: {
|
cellWidth,
|
||||||
x: child.position.x - minX + startX,
|
cellHeight,
|
||||||
y: child.position.y - minY + startY,
|
rows,
|
||||||
z: child.position.z || 1,
|
columns,
|
||||||
},
|
visible: true,
|
||||||
}));
|
snapToGrid: true,
|
||||||
|
gridColor: "#e5e7eb",
|
||||||
console.log("✅ 정규화 완료:", {
|
gridOpacity: 0.5,
|
||||||
normalizedPositions: normalizedChildren.map((c) => ({ id: c.id, pos: c.position })),
|
};
|
||||||
});
|
}
|
||||||
|
|
||||||
return normalizedChildren;
|
/**
|
||||||
|
* 위치가 페이지 경계 내에 있는지 확인
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
|
||||||
|
return (
|
||||||
|
component.x >= minX &&
|
||||||
|
component.y >= minY &&
|
||||||
|
component.x + component.width <= maxX &&
|
||||||
|
component.y + component.height <= maxY
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 컴포넌트를 페이지 경계 내로 제한
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
|
||||||
|
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;
|
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 {
|
export interface ReportPage {
|
||||||
page_id: string;
|
page_id: string;
|
||||||
|
|
@ -96,6 +108,7 @@ export interface ReportPage {
|
||||||
right: number;
|
right: number;
|
||||||
};
|
};
|
||||||
background_color: string;
|
background_color: string;
|
||||||
|
gridConfig?: GridConfig; // 그리드 설정 (옵셔널)
|
||||||
components: ComponentConfig[];
|
components: ComponentConfig[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -113,6 +126,11 @@ export interface ComponentConfig {
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
zIndex: number;
|
zIndex: number;
|
||||||
|
// 그리드 좌표 (옵셔널)
|
||||||
|
gridX?: number; // 시작 열 (0부터 시작)
|
||||||
|
gridY?: number; // 시작 행 (0부터 시작)
|
||||||
|
gridWidth?: number; // 차지하는 열 수
|
||||||
|
gridHeight?: number; // 차지하는 행 수
|
||||||
fontSize?: number;
|
fontSize?: number;
|
||||||
fontFamily?: string;
|
fontFamily?: string;
|
||||||
fontWeight?: string;
|
fontWeight?: string;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue