레포트관리에 그리드 시스템 1차 적용(2차적인 개선 필요)

This commit is contained in:
dohyeons 2025-10-13 15:08:31 +09:00
parent 71eb308bba
commit 32024a6d70
9 changed files with 1341 additions and 453 deletions

View File

@ -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

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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

View File

@ -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>

View File

@ -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,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 다운로드
const handleDownloadWord = async () => {
setIsExporting(true);
try {
// 컴포넌트를 Paragraph로 변환
const paragraphs: (Paragraph | Table)[] = [];
// 모든 페이지의 컴포넌트 수집
const allComponents = layoutConfig.pages
// 페이지별로 섹션 생성
const sections = layoutConfig.pages
.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 좌표로 정렬
const sortedComponents = [...allComponents].sort((a, b) => a.y - b.y);
// 페이지 내 컴포넌트를 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) {
if (component.type === "text" || component.type === "label") {
const value = getComponentValue(component);
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 },
}),
);
// 컴포넌트들을 행으로 그룹화 (같은 Y 범위 = 같은 행)
const rows: Array<Array<(typeof sortedComponents)[0]>> = [];
const rowTolerance = 20; // Y 좌표 허용 오차
// 테이블 행
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 },
});
paragraphs.push(table);
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 생성 및 다운로드

View File

@ -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>;

View File

@ -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;
/**
*
*/
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 };
if (xOverlap && yOverlap) {
return true;
}
}
console.log("📏 calculateOptimalGroupSize 시작:", {
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;
return false;
}
/**
*
* /
*/
export function normalizeGroupChildPositions(children: any[], gridSettings: GridSettings): any[] {
if (!gridSettings.snapToGrid || children.length === 0) return children;
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;
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),
};
}
/**
*
*/
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,
};
}
/**
*
*/
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)),
};
}

View File

@ -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;