페이지 관리 시스템 전체 구현
This commit is contained in:
parent
fdc476a9e0
commit
c9c416d6fd
|
|
@ -0,0 +1,388 @@
|
|||
# 리포트 페이지 관리 시스템 설계
|
||||
|
||||
## 1. 개요
|
||||
|
||||
리포트 디자이너에 다중 페이지 관리 기능을 추가하여 여러 페이지에 걸친 복잡한 문서를 작성할 수 있도록 합니다.
|
||||
|
||||
## 2. 주요 기능
|
||||
|
||||
### 2.1 페이지 관리
|
||||
|
||||
- 페이지 추가/삭제
|
||||
- 페이지 복사
|
||||
- 페이지 순서 변경 (드래그 앤 드롭)
|
||||
- 페이지 이름 지정
|
||||
|
||||
### 2.2 페이지 네비게이션
|
||||
|
||||
- 좌측 페이지 썸네일 패널
|
||||
- 페이지 간 전환 (클릭)
|
||||
- 이전/다음 페이지 이동
|
||||
- 페이지 번호 표시
|
||||
|
||||
### 2.3 페이지별 설정
|
||||
|
||||
- 페이지 크기 (A4, A3, Letter, 사용자 정의)
|
||||
- 페이지 방향 (세로/가로)
|
||||
- 여백 설정
|
||||
- 배경색
|
||||
|
||||
### 2.4 컴포넌트 관리
|
||||
|
||||
- 컴포넌트는 특정 페이지에 속함
|
||||
- 페이지 간 컴포넌트 복사/이동
|
||||
- 현재 페이지의 컴포넌트만 표시
|
||||
|
||||
## 3. 데이터베이스 스키마
|
||||
|
||||
### 3.1 기존 구조 활용 (변경 없음)
|
||||
|
||||
**report_layout 테이블의 layout_config (JSONB) 활용**
|
||||
|
||||
기존:
|
||||
|
||||
```json
|
||||
{
|
||||
"width": 210,
|
||||
"height": 297,
|
||||
"orientation": "portrait",
|
||||
"components": [...]
|
||||
}
|
||||
```
|
||||
|
||||
변경 후:
|
||||
|
||||
```json
|
||||
{
|
||||
"pages": [
|
||||
{
|
||||
"page_id": "page-uuid-1",
|
||||
"page_name": "표지",
|
||||
"page_order": 0,
|
||||
"width": 210,
|
||||
"height": 297,
|
||||
"orientation": "portrait",
|
||||
"margins": {
|
||||
"top": 20,
|
||||
"bottom": 20,
|
||||
"left": 20,
|
||||
"right": 20
|
||||
},
|
||||
"background_color": "#ffffff",
|
||||
"components": [
|
||||
{
|
||||
"id": "comp-1",
|
||||
"type": "text",
|
||||
"x": 100,
|
||||
"y": 50,
|
||||
...
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"page_id": "page-uuid-2",
|
||||
"page_name": "본문",
|
||||
"page_order": 1,
|
||||
"width": 210,
|
||||
"height": 297,
|
||||
"orientation": "portrait",
|
||||
"margins": { "top": 20, "bottom": 20, "left": 20, "right": 20 },
|
||||
"background_color": "#ffffff",
|
||||
"components": [...]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 마이그레이션 전략
|
||||
|
||||
기존 단일 페이지 리포트 자동 변환:
|
||||
|
||||
```typescript
|
||||
// 기존 구조 감지 시
|
||||
if (layoutConfig.components && !layoutConfig.pages) {
|
||||
// 자동으로 pages 구조로 변환
|
||||
layoutConfig = {
|
||||
pages: [
|
||||
{
|
||||
page_id: uuidv4(),
|
||||
page_name: "페이지 1",
|
||||
page_order: 0,
|
||||
width: layoutConfig.width || 210,
|
||||
height: layoutConfig.height || 297,
|
||||
orientation: layoutConfig.orientation || "portrait",
|
||||
margins: { top: 20, bottom: 20, left: 20, right: 20 },
|
||||
background_color: "#ffffff",
|
||||
components: layoutConfig.components,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## 4. 프론트엔드 구조
|
||||
|
||||
### 4.1 타입 정의 (types/report.ts)
|
||||
|
||||
```typescript
|
||||
export interface ReportPage {
|
||||
page_id: string;
|
||||
report_id: string;
|
||||
page_order: number;
|
||||
page_name: string;
|
||||
|
||||
// 페이지 설정
|
||||
width: number;
|
||||
height: number;
|
||||
orientation: 'portrait' | 'landscape';
|
||||
|
||||
// 여백
|
||||
margin_top: number;
|
||||
margin_bottom: number;
|
||||
margin_left: number;
|
||||
margin_right: number;
|
||||
|
||||
// 배경
|
||||
background_color: string;
|
||||
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface ComponentConfig {
|
||||
id: string;
|
||||
// page_id 불필요 (페이지의 components 배열에 포함됨)
|
||||
type: 'text' | 'label' | 'image' | 'table' | ...;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
// ... 기타 속성
|
||||
}
|
||||
|
||||
export interface ReportLayoutConfig {
|
||||
pages: ReportPage[];
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 Context 구조 변경
|
||||
|
||||
```typescript
|
||||
interface ReportDesignerContextType {
|
||||
// 페이지 관리
|
||||
pages: ReportPage[];
|
||||
currentPageId: string | null;
|
||||
currentPage: ReportPage | null;
|
||||
|
||||
addPage: () => void;
|
||||
deletePage: (pageId: string) => void;
|
||||
duplicatePage: (pageId: string) => void;
|
||||
reorderPages: (sourceIndex: number, targetIndex: number) => void;
|
||||
selectPage: (pageId: string) => void;
|
||||
updatePage: (pageId: string, updates: Partial<ReportPage>) => void;
|
||||
|
||||
// 컴포넌트 (현재 페이지만)
|
||||
currentPageComponents: ComponentConfig[];
|
||||
|
||||
// ... 기존 기능들
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 UI 구조
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ ReportDesignerToolbar (저장, 미리보기, 페이지 추가 등) │
|
||||
├──────────┬────────────────────────────────────┬─────────────┤
|
||||
│ │ │ │
|
||||
│ PageList │ ReportDesignerCanvas │ Right │
|
||||
│ (좌측) │ (현재 페이지만 표시) │ Panel │
|
||||
│ │ │ (속성) │
|
||||
│ - Page 1 │ ┌──────────────────────────┐ │ │
|
||||
│ - Page 2 │ │ │ │ │
|
||||
│ * Page 3 │ │ [컴포넌트들] │ │ │
|
||||
│ (현재) │ │ │ │ │
|
||||
│ │ └──────────────────────────┘ │ │
|
||||
│ [+ 추가] │ │ │
|
||||
│ │ 이전 | 다음 (페이지 네비게이션) │ │
|
||||
└──────────┴────────────────────────────────────┴─────────────┘
|
||||
```
|
||||
|
||||
## 5. 컴포넌트 구조
|
||||
|
||||
### 5.1 새 컴포넌트
|
||||
|
||||
#### PageListPanel.tsx
|
||||
|
||||
```typescript
|
||||
- 좌측 페이지 목록 패널
|
||||
- 페이지 썸네일 표시
|
||||
- 드래그 앤 드롭으로 순서 변경
|
||||
- 페이지 추가/삭제/복사 버튼
|
||||
- 현재 페이지 하이라이트
|
||||
```
|
||||
|
||||
#### PageNavigator.tsx
|
||||
|
||||
```typescript
|
||||
- 캔버스 하단의 페이지 네비게이션
|
||||
- 이전/다음 버튼
|
||||
- 현재 페이지 번호 표시
|
||||
- 페이지 점프 (1/5 형식)
|
||||
```
|
||||
|
||||
#### PageSettingsPanel.tsx
|
||||
|
||||
```typescript
|
||||
- 우측 패널 내 페이지 설정 섹션
|
||||
- 페이지 크기, 방향
|
||||
- 여백 설정
|
||||
- 배경색
|
||||
```
|
||||
|
||||
### 5.2 수정할 컴포넌트
|
||||
|
||||
#### ReportDesignerContext.tsx
|
||||
|
||||
- pages 상태 추가
|
||||
- currentPageId 상태 추가
|
||||
- 페이지 관리 함수들 추가
|
||||
- components를 currentPageComponents로 필터링
|
||||
|
||||
#### ReportDesignerCanvas.tsx
|
||||
|
||||
- currentPageComponents만 렌더링
|
||||
- 캔버스 크기를 currentPage 기준으로 설정
|
||||
- 컴포넌트 추가 시 page_id 포함
|
||||
|
||||
#### ReportDesignerToolbar.tsx
|
||||
|
||||
- "페이지 추가" 버튼 추가
|
||||
- 저장 시 pages도 함께 저장
|
||||
|
||||
#### ReportPreviewModal.tsx
|
||||
|
||||
- 모든 페이지 순서대로 미리보기
|
||||
- 페이지 구분선 표시
|
||||
- PDF 저장 시 모든 페이지 포함
|
||||
|
||||
## 6. API 엔드포인트
|
||||
|
||||
### 6.1 페이지 관리
|
||||
|
||||
```typescript
|
||||
// 페이지 목록 조회
|
||||
GET /api/report/:reportId/pages
|
||||
Response: { pages: ReportPage[] }
|
||||
|
||||
// 페이지 생성
|
||||
POST /api/report/:reportId/pages
|
||||
Body: { page_name, width, height, orientation, margins }
|
||||
Response: { page: ReportPage }
|
||||
|
||||
// 페이지 수정
|
||||
PUT /api/report/pages/:pageId
|
||||
Body: Partial<ReportPage>
|
||||
Response: { page: ReportPage }
|
||||
|
||||
// 페이지 삭제
|
||||
DELETE /api/report/pages/:pageId
|
||||
Response: { success: boolean }
|
||||
|
||||
// 페이지 순서 변경
|
||||
PUT /api/report/:reportId/pages/reorder
|
||||
Body: { pageOrders: Array<{ page_id, page_order }> }
|
||||
Response: { success: boolean }
|
||||
|
||||
// 페이지 복사
|
||||
POST /api/report/pages/:pageId/duplicate
|
||||
Response: { page: ReportPage }
|
||||
```
|
||||
|
||||
### 6.2 레이아웃 (기존 수정)
|
||||
|
||||
```typescript
|
||||
// 레이아웃 저장 (페이지별)
|
||||
PUT /api/report/:reportId/layout
|
||||
Body: {
|
||||
pages: ReportPage[],
|
||||
components: ComponentConfig[] // page_id 포함
|
||||
}
|
||||
```
|
||||
|
||||
## 7. 구현 단계
|
||||
|
||||
### Phase 1: DB 및 백엔드 (0.5일)
|
||||
|
||||
1. ✅ DB 스키마 생성
|
||||
2. ✅ API 엔드포인트 구현
|
||||
3. ✅ 기존 리포트 마이그레이션 (단일 페이지 생성)
|
||||
|
||||
### Phase 2: 타입 및 Context (0.5일)
|
||||
|
||||
1. ✅ 타입 정의 업데이트
|
||||
2. ✅ Context에 페이지 상태/함수 추가
|
||||
3. ✅ API 연동
|
||||
|
||||
### Phase 3: UI 컴포넌트 (1일)
|
||||
|
||||
1. ✅ PageListPanel 구현
|
||||
2. ✅ PageNavigator 구현
|
||||
3. ✅ PageSettingsPanel 구현
|
||||
|
||||
### Phase 4: 통합 및 수정 (1일)
|
||||
|
||||
1. ✅ Canvas에서 현재 페이지만 표시
|
||||
2. ✅ 컴포넌트 추가/수정 시 page_id 처리
|
||||
3. ✅ 미리보기에서 모든 페이지 표시
|
||||
4. ✅ PDF/WORD 저장에서 모든 페이지 처리
|
||||
|
||||
### Phase 5: 테스트 및 최적화 (0.5일)
|
||||
|
||||
1. ✅ 페이지 전환 성능 확인
|
||||
2. ✅ 썸네일 렌더링 최적화
|
||||
3. ✅ 버그 수정
|
||||
|
||||
**총 예상 기간: 3-4일**
|
||||
|
||||
## 8. 주의사항
|
||||
|
||||
### 8.1 성능 최적화
|
||||
|
||||
- 페이지 썸네일은 저해상도로 렌더링
|
||||
- 현재 페이지 컴포넌트만 DOM에 유지
|
||||
- 페이지 전환 시 애니메이션 최소화
|
||||
|
||||
### 8.2 호환성
|
||||
|
||||
- 기존 리포트는 자동으로 단일 페이지로 마이그레이션
|
||||
- 템플릿도 페이지 구조 포함
|
||||
|
||||
### 8.3 사용자 경험
|
||||
|
||||
- 페이지 삭제 시 확인 다이얼로그
|
||||
- 컴포넌트가 있는 페이지 삭제 시 경고
|
||||
- 페이지 순서 변경 시 즉시 반영
|
||||
|
||||
## 9. 추후 확장 기능
|
||||
|
||||
### 9.1 페이지 템플릿
|
||||
|
||||
- 자주 사용하는 페이지 레이아웃 저장
|
||||
- 페이지 추가 시 템플릿 선택
|
||||
|
||||
### 9.2 마스터 페이지
|
||||
|
||||
- 모든 페이지에 공통으로 적용되는 헤더/푸터
|
||||
- 페이지 번호 자동 삽입
|
||||
|
||||
### 9.3 페이지 연결
|
||||
|
||||
- 테이블 데이터가 여러 페이지에 자동 분할
|
||||
- 페이지 오버플로우 처리
|
||||
|
||||
## 10. 참고 자료
|
||||
|
||||
- 오즈리포트 메뉴얼
|
||||
- Crystal Reports 페이지 관리
|
||||
- Adobe InDesign 페이지 시스템
|
||||
|
|
@ -5,6 +5,7 @@ import { useParams, useRouter } from "next/navigation";
|
|||
import { DndProvider } from "react-dnd";
|
||||
import { HTML5Backend } from "react-dnd-html5-backend";
|
||||
import { ReportDesignerToolbar } from "@/components/report/designer/ReportDesignerToolbar";
|
||||
import { PageListPanel } from "@/components/report/designer/PageListPanel";
|
||||
import { ReportDesignerLeftPanel } from "@/components/report/designer/ReportDesignerLeftPanel";
|
||||
import { ReportDesignerCanvas } from "@/components/report/designer/ReportDesignerCanvas";
|
||||
import { ReportDesignerRightPanel } from "@/components/report/designer/ReportDesignerRightPanel";
|
||||
|
|
@ -72,13 +73,16 @@ export default function ReportDesignerPage() {
|
|||
|
||||
{/* 메인 영역 */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* 좌측 패널 */}
|
||||
{/* 페이지 목록 패널 */}
|
||||
<PageListPanel />
|
||||
|
||||
{/* 좌측 패널 (템플릿, 컴포넌트) */}
|
||||
<ReportDesignerLeftPanel />
|
||||
|
||||
{/* 중앙 캔버스 */}
|
||||
<ReportDesignerCanvas />
|
||||
|
||||
{/* 우측 패널 */}
|
||||
{/* 우측 패널 (속성) */}
|
||||
<ReportDesignerRightPanel />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,240 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||
import { Plus, Copy, Trash2, GripVertical, Edit2, Check, X } from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
export function PageListPanel() {
|
||||
const {
|
||||
layoutConfig,
|
||||
currentPageId,
|
||||
addPage,
|
||||
deletePage,
|
||||
duplicatePage,
|
||||
reorderPages,
|
||||
selectPage,
|
||||
updatePageSettings,
|
||||
} = useReportDesigner();
|
||||
|
||||
const [editingPageId, setEditingPageId] = useState<string | null>(null);
|
||||
const [editingName, setEditingName] = useState("");
|
||||
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
||||
|
||||
const handleStartEdit = (pageId: string, currentName: string) => {
|
||||
setEditingPageId(pageId);
|
||||
setEditingName(currentName);
|
||||
};
|
||||
|
||||
const handleSaveEdit = () => {
|
||||
if (editingPageId && editingName.trim()) {
|
||||
updatePageSettings(editingPageId, { page_name: editingName.trim() });
|
||||
}
|
||||
setEditingPageId(null);
|
||||
setEditingName("");
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditingPageId(null);
|
||||
setEditingName("");
|
||||
};
|
||||
|
||||
const handleDragStart = (index: number) => {
|
||||
setDraggedIndex(index);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent, index: number) => {
|
||||
e.preventDefault();
|
||||
if (draggedIndex === null || draggedIndex === index) return;
|
||||
|
||||
// 실시간으로 순서 변경하지 않고, drop 시에만 변경
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent, targetIndex: number) => {
|
||||
e.preventDefault();
|
||||
if (draggedIndex === null) return;
|
||||
|
||||
const sourceIndex = draggedIndex;
|
||||
if (sourceIndex !== targetIndex) {
|
||||
reorderPages(sourceIndex, targetIndex);
|
||||
}
|
||||
|
||||
setDraggedIndex(null);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setDraggedIndex(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-background flex h-full w-64 flex-col border-r">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between border-b p-3">
|
||||
<h3 className="text-sm font-semibold">페이지 목록</h3>
|
||||
<Button size="sm" variant="ghost" onClick={() => addPage()}>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 페이지 목록 */}
|
||||
<ScrollArea className="flex-1 p-2">
|
||||
<div className="space-y-2">
|
||||
{layoutConfig.pages
|
||||
.sort((a, b) => a.page_order - b.page_order)
|
||||
.map((page, index) => (
|
||||
<Card
|
||||
key={page.page_id}
|
||||
draggable
|
||||
onDragStart={() => handleDragStart(index)}
|
||||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
onDrop={(e) => handleDrop(e, index)}
|
||||
onDragEnd={handleDragEnd}
|
||||
className={`group relative cursor-pointer transition-all hover:shadow-md ${
|
||||
page.page_id === currentPageId
|
||||
? "border-primary bg-primary/5 ring-primary/20 ring-2"
|
||||
: "border-border hover:border-primary/50"
|
||||
} ${draggedIndex === index ? "opacity-50" : ""}`}
|
||||
onClick={() => selectPage(page.page_id)}
|
||||
>
|
||||
<div className="p-3">
|
||||
{/* 드래그 핸들 & 페이지 정보 */}
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="text-muted-foreground cursor-grab pt-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
{/* 페이지 이름 편집 */}
|
||||
{editingPageId === page.page_id ? (
|
||||
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||||
<Input
|
||||
value={editingName}
|
||||
onChange={(e) => setEditingName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleSaveEdit();
|
||||
if (e.key === "Escape") handleCancelEdit();
|
||||
}}
|
||||
className="h-6 text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
<Button size="sm" variant="ghost" className="h-6 w-6 p-0" onClick={handleSaveEdit}>
|
||||
<Check className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="h-6 w-6 p-0" onClick={handleCancelEdit}>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-medium">{page.page_name}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-5 w-5 p-0 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleStartEdit(page.page_id, page.page_name);
|
||||
}}
|
||||
>
|
||||
<Edit2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 페이지 정보 */}
|
||||
<div className="text-muted-foreground mt-1 flex items-center gap-2 text-xs">
|
||||
<span>
|
||||
{page.width} x {page.height}mm
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>{page.components.length}개 컴포넌트</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 액션 메뉴 */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
>
|
||||
<span className="sr-only">메뉴</span>
|
||||
<span className="text-xl leading-none">⋮</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
duplicatePage(page.page_id);
|
||||
}}
|
||||
>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
복제
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deletePage(page.page_id);
|
||||
}}
|
||||
disabled={layoutConfig.pages.length <= 1}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
삭제
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* 썸네일 (간단한 미리보기) */}
|
||||
<div className="mt-2 aspect-[210/297] overflow-hidden rounded border bg-white">
|
||||
<div className="relative h-full w-full origin-top-left scale-[0.15]">
|
||||
<div
|
||||
className="absolute inset-0 bg-white"
|
||||
style={{
|
||||
width: `${page.width * 3.7795}px`,
|
||||
height: `${page.height * 3.7795}px`,
|
||||
}}
|
||||
>
|
||||
{/* 간단한 컴포넌트 표시 */}
|
||||
{page.components.slice(0, 10).map((comp) => (
|
||||
<div
|
||||
key={comp.id}
|
||||
className="border-primary/20 bg-primary/5 absolute border"
|
||||
style={{
|
||||
left: `${comp.x}px`,
|
||||
top: `${comp.y}px`,
|
||||
width: `${comp.width}px`,
|
||||
height: `${comp.height}px`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* 푸터 */}
|
||||
<div className="border-t p-2">
|
||||
<Button size="sm" variant="outline" className="w-full" onClick={() => addPage()}>
|
||||
<Plus className="mr-2 h-4 w-4" />새 페이지 추가
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -11,6 +11,8 @@ import { v4 as uuidv4 } from "uuid";
|
|||
export function ReportDesignerCanvas() {
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
const {
|
||||
currentPageId,
|
||||
currentPage,
|
||||
components,
|
||||
addComponent,
|
||||
updateComponent,
|
||||
|
|
@ -259,10 +261,24 @@ export function ReportDesignerCanvas() {
|
|||
redo,
|
||||
]);
|
||||
|
||||
// 페이지가 없는 경우
|
||||
if (!currentPageId || !currentPage) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center bg-gray-100">
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-700">페이지가 없습니다</h3>
|
||||
<p className="mt-2 text-sm text-gray-500">좌측에서 페이지를 추가하세요.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden bg-gray-100">
|
||||
{/* 작업 영역 제목 */}
|
||||
<div className="border-b bg-white px-4 py-2 text-center text-sm font-medium text-gray-700">작업 영역</div>
|
||||
<div className="border-b bg-white px-4 py-2 text-center text-sm font-medium text-gray-700">
|
||||
{currentPage.page_name} ({currentPage.width} x {currentPage.height}mm)
|
||||
</div>
|
||||
|
||||
{/* 캔버스 스크롤 영역 */}
|
||||
<div className="flex flex-1 items-center justify-center overflow-auto p-8">
|
||||
|
|
|
|||
|
|
@ -17,7 +17,16 @@ import { useToast } from "@/hooks/use-toast";
|
|||
|
||||
export function ReportDesignerRightPanel() {
|
||||
const context = useReportDesigner();
|
||||
const { selectedComponentId, components, updateComponent, removeComponent, queries } = context;
|
||||
const {
|
||||
selectedComponentId,
|
||||
components,
|
||||
updateComponent,
|
||||
removeComponent,
|
||||
queries,
|
||||
currentPage,
|
||||
currentPageId,
|
||||
updatePageSettings,
|
||||
} = context;
|
||||
const [activeTab, setActiveTab] = useState<string>("properties");
|
||||
const [uploadingImage, setUploadingImage] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
|
@ -91,13 +100,17 @@ 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-2">
|
||||
<TabsTrigger value="properties" className="gap-2">
|
||||
<Settings className="h-4 w-4" />
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="page" className="gap-1 text-xs">
|
||||
<Settings className="h-3 w-3" />
|
||||
페이지
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="properties" className="gap-1 text-xs">
|
||||
<Settings className="h-3 w-3" />
|
||||
속성
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="queries" className="gap-2">
|
||||
<Database className="h-4 w-4" />
|
||||
<TabsTrigger value="queries" className="gap-1 text-xs">
|
||||
<Database className="h-3 w-3" />
|
||||
쿼리
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
|
@ -1114,6 +1127,296 @@ export function ReportDesignerRightPanel() {
|
|||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
{/* 페이지 설정 탭 */}
|
||||
<TabsContent value="page" className="mt-0 h-[calc(100vh-120px)]">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 p-4">
|
||||
{currentPage && currentPageId ? (
|
||||
<>
|
||||
{/* 페이지 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">페이지 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div>
|
||||
<Label className="text-xs">페이지 이름</Label>
|
||||
<Input
|
||||
value={currentPage.page_name}
|
||||
onChange={(e) =>
|
||||
updatePageSettings(currentPageId, {
|
||||
page_name: e.target.value,
|
||||
})
|
||||
}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 페이지 크기 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">페이지 크기</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label className="text-xs">너비 (mm)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={currentPage.width}
|
||||
onChange={(e) =>
|
||||
updatePageSettings(currentPageId, {
|
||||
width: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">높이 (mm)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={currentPage.height}
|
||||
onChange={(e) =>
|
||||
updatePageSettings(currentPageId, {
|
||||
height: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs">방향</Label>
|
||||
<Select
|
||||
value={currentPage.orientation}
|
||||
onValueChange={(value: "portrait" | "landscape") =>
|
||||
updatePageSettings(currentPageId, {
|
||||
orientation: value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="portrait">세로 (Portrait)</SelectItem>
|
||||
<SelectItem value="landscape">가로 (Landscape)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 프리셋 버튼 */}
|
||||
<div className="grid grid-cols-2 gap-2 pt-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
updatePageSettings(currentPageId, {
|
||||
width: 210,
|
||||
height: 297,
|
||||
orientation: "portrait",
|
||||
})
|
||||
}
|
||||
>
|
||||
A4 세로
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
updatePageSettings(currentPageId, {
|
||||
width: 297,
|
||||
height: 210,
|
||||
orientation: "landscape",
|
||||
})
|
||||
}
|
||||
>
|
||||
A4 가로
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 여백 설정 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">여백 (mm)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label className="text-xs">상단</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={currentPage.margins.top}
|
||||
onChange={(e) =>
|
||||
updatePageSettings(currentPageId, {
|
||||
margins: {
|
||||
...currentPage.margins,
|
||||
top: Number(e.target.value),
|
||||
},
|
||||
})
|
||||
}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">하단</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={currentPage.margins.bottom}
|
||||
onChange={(e) =>
|
||||
updatePageSettings(currentPageId, {
|
||||
margins: {
|
||||
...currentPage.margins,
|
||||
bottom: Number(e.target.value),
|
||||
},
|
||||
})
|
||||
}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">좌측</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={currentPage.margins.left}
|
||||
onChange={(e) =>
|
||||
updatePageSettings(currentPageId, {
|
||||
margins: {
|
||||
...currentPage.margins,
|
||||
left: Number(e.target.value),
|
||||
},
|
||||
})
|
||||
}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">우측</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={currentPage.margins.right}
|
||||
onChange={(e) =>
|
||||
updatePageSettings(currentPageId, {
|
||||
margins: {
|
||||
...currentPage.margins,
|
||||
right: Number(e.target.value),
|
||||
},
|
||||
})
|
||||
}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 여백 프리셋 */}
|
||||
<div className="grid grid-cols-3 gap-2 pt-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
updatePageSettings(currentPageId, {
|
||||
margins: { top: 10, bottom: 10, left: 10, right: 10 },
|
||||
})
|
||||
}
|
||||
>
|
||||
좁게
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
updatePageSettings(currentPageId, {
|
||||
margins: { top: 20, bottom: 20, left: 20, right: 20 },
|
||||
})
|
||||
}
|
||||
>
|
||||
보통
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
updatePageSettings(currentPageId, {
|
||||
margins: { top: 30, bottom: 30, left: 30, right: 30 },
|
||||
})
|
||||
}
|
||||
>
|
||||
넓게
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 배경색 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">배경</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div>
|
||||
<Label className="text-xs">배경색</Label>
|
||||
<div className="mt-1 flex gap-2">
|
||||
<Input
|
||||
type="color"
|
||||
value={currentPage.background_color}
|
||||
onChange={(e) =>
|
||||
updatePageSettings(currentPageId, {
|
||||
background_color: e.target.value,
|
||||
})
|
||||
}
|
||||
className="h-10 w-20"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={currentPage.background_color}
|
||||
onChange={(e) =>
|
||||
updatePageSettings(currentPageId, {
|
||||
background_color: e.target.value,
|
||||
})
|
||||
}
|
||||
className="flex-1"
|
||||
placeholder="#ffffff"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 배경색 프리셋 */}
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{["#ffffff", "#f3f4f6", "#e5e7eb", "#d1d5db"].map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
onClick={() =>
|
||||
updatePageSettings(currentPageId, {
|
||||
background_color: color,
|
||||
})
|
||||
}
|
||||
className="h-8 rounded border-2 transition-all hover:scale-110"
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
borderColor: currentPage.background_color === color ? "#3b82f6" : "#d1d5db",
|
||||
}}
|
||||
title={color}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-muted-foreground text-sm">페이지를 선택하세요</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
{/* 쿼리 탭 */}
|
||||
<TabsContent value="queries" className="mt-0 h-[calc(100vh-120px)]">
|
||||
<QueryManager />
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ interface ReportPreviewModalProps {
|
|||
}
|
||||
|
||||
export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps) {
|
||||
const { components, canvasWidth, canvasHeight, getQueryResult, reportDetail } = useReportDesigner();
|
||||
const { layoutConfig, getQueryResult, reportDetail } = useReportDesigner();
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
|
|
@ -53,10 +53,14 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
|
|||
printWindow.print();
|
||||
};
|
||||
|
||||
// HTML 생성 (인쇄/PDF용)
|
||||
const generatePrintHTML = (): string => {
|
||||
// 컴포넌트별 HTML 생성
|
||||
const componentsHTML = components
|
||||
// 페이지별 컴포넌트 HTML 생성
|
||||
const generatePageHTML = (
|
||||
pageComponents: any[],
|
||||
pageWidth: number,
|
||||
pageHeight: number,
|
||||
backgroundColor: string,
|
||||
): string => {
|
||||
const componentsHTML = pageComponents
|
||||
.map((component) => {
|
||||
const queryResult = component.queryId ? getQueryResult(component.queryId) : null;
|
||||
let content = "";
|
||||
|
|
@ -152,7 +156,7 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
|
|||
.map(
|
||||
(row) => `
|
||||
<tr>
|
||||
${columns.map((col) => `<td style="border: ${component.showBorder !== false ? "1px solid #d1d5db" : "none"}; padding: 6px 8px; text-align: ${col.align || "left"}; height: ${component.rowHeight || "auto"}px;">${String(row[col.field] ?? "")}</td>`).join("")}
|
||||
${columns.map((col: { field: string; align?: string }) => `<td style="border: ${component.showBorder !== false ? "1px solid #d1d5db" : "none"}; padding: 6px 8px; text-align: ${col.align || "left"}; height: ${component.rowHeight || "auto"}px;">${String(row[col.field] ?? "")}</td>`).join("")}
|
||||
</tr>
|
||||
`,
|
||||
)
|
||||
|
|
@ -162,7 +166,7 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
|
|||
<table style="width: 100%; border-collapse: ${component.showBorder !== false ? "collapse" : "separate"}; font-size: 12px;">
|
||||
<thead>
|
||||
<tr style="background-color: ${component.headerBackgroundColor || "#f3f4f6"}; color: ${component.headerTextColor || "#111827"};">
|
||||
${columns.map((col) => `<th style="border: ${component.showBorder !== false ? "1px solid #d1d5db" : "none"}; padding: 6px 8px; text-align: ${col.align || "left"}; width: ${col.width ? `${col.width}px` : "auto"}; font-weight: 600;">${col.header}</th>`).join("")}
|
||||
${columns.map((col: { header: string; align?: string; width?: number }) => `<th style="border: ${component.showBorder !== false ? "1px solid #d1d5db" : "none"}; padding: 6px 8px; text-align: ${col.align || "left"}; width: ${col.width ? `${col.width}px` : "auto"}; font-weight: 600;">${col.header}</th>`).join("")}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -178,6 +182,19 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
|
|||
})
|
||||
.join("");
|
||||
|
||||
return `
|
||||
<div style="position: relative; width: ${pageWidth}mm; min-height: ${pageHeight}mm; background-color: ${backgroundColor}; margin: 0 auto;">
|
||||
${componentsHTML}
|
||||
</div>`;
|
||||
};
|
||||
|
||||
// 모든 페이지 HTML 생성 (인쇄/PDF용)
|
||||
const generatePrintHTML = (): string => {
|
||||
const pagesHTML = layoutConfig.pages
|
||||
.sort((a, b) => a.page_order - b.page_order)
|
||||
.map((page) => generatePageHTML(page.components, page.width, page.height, page.background_color))
|
||||
.join('<div style="page-break-after: always;"></div>');
|
||||
|
||||
return `
|
||||
<html>
|
||||
<head>
|
||||
|
|
@ -191,7 +208,8 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
|
|||
}
|
||||
@media print {
|
||||
body { margin: 0; padding: 0; }
|
||||
.print-container { page-break-inside: avoid; }
|
||||
.print-page { page-break-after: always; page-break-inside: avoid; }
|
||||
.print-page:last-child { page-break-after: auto; }
|
||||
}
|
||||
body {
|
||||
font-family: 'Malgun Gothic', 'Apple SD Gothic Neo', sans-serif;
|
||||
|
|
@ -200,19 +218,10 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
|
|||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
.print-container {
|
||||
position: relative;
|
||||
width: ${canvasWidth}mm;
|
||||
min-height: ${canvasHeight}mm;
|
||||
background-color: white;
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="print-container">
|
||||
${componentsHTML}
|
||||
</div>
|
||||
${pagesHTML}
|
||||
<script>
|
||||
window.onload = function() {
|
||||
// 이미지 로드 대기 후 인쇄
|
||||
|
|
@ -266,8 +275,13 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
|
|||
// 컴포넌트를 Paragraph로 변환
|
||||
const paragraphs: (Paragraph | Table)[] = [];
|
||||
|
||||
// 모든 페이지의 컴포넌트 수집
|
||||
const allComponents = layoutConfig.pages
|
||||
.sort((a, b) => a.page_order - b.page_order)
|
||||
.flatMap((page) => page.components);
|
||||
|
||||
// Y 좌표로 정렬
|
||||
const sortedComponents = [...components].sort((a, b) => a.y - b.y);
|
||||
const sortedComponents = [...allComponents].sort((a, b) => a.y - b.y);
|
||||
|
||||
for (const component of sortedComponents) {
|
||||
if (component.type === "text" || component.type === "label") {
|
||||
|
|
@ -370,17 +384,28 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
|
|||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 미리보기 영역 */}
|
||||
{/* 미리보기 영역 - 모든 페이지 표시 */}
|
||||
<div className="max-h-[500px] overflow-auto rounded border bg-gray-100 p-4">
|
||||
<div className="space-y-4">
|
||||
{layoutConfig.pages
|
||||
.sort((a, b) => a.page_order - b.page_order)
|
||||
.map((page) => (
|
||||
<div key={page.page_id} className="relative">
|
||||
{/* 페이지 번호 라벨 */}
|
||||
<div className="mb-2 text-center text-xs text-gray-500">
|
||||
페이지 {page.page_order + 1} - {page.page_name}
|
||||
</div>
|
||||
|
||||
{/* 페이지 컨텐츠 */}
|
||||
<div
|
||||
id="preview-content"
|
||||
className="relative mx-auto bg-white shadow-lg"
|
||||
className="relative mx-auto shadow-lg"
|
||||
style={{
|
||||
width: `${canvasWidth}mm`,
|
||||
minHeight: `${canvasHeight}mm`,
|
||||
width: `${page.width}mm`,
|
||||
minHeight: `${page.height}mm`,
|
||||
backgroundColor: page.background_color,
|
||||
}}
|
||||
>
|
||||
{components.map((component) => {
|
||||
{page.components.map((component) => {
|
||||
const displayValue = getComponentValue(component);
|
||||
const queryResult = component.queryId ? getQueryResult(component.queryId) : null;
|
||||
|
||||
|
|
@ -511,7 +536,8 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
|
|||
{component.type === "divider" && (
|
||||
<div
|
||||
style={{
|
||||
width: component.orientation === "horizontal" ? "100%" : `${component.lineWidth || 1}px`,
|
||||
width:
|
||||
component.orientation === "horizontal" ? "100%" : `${component.lineWidth || 1}px`,
|
||||
height: component.orientation === "vertical" ? "100%" : `${component.lineWidth || 1}px`,
|
||||
backgroundColor: component.lineColor || "#000000",
|
||||
...(component.lineStyle === "dashed" && {
|
||||
|
|
@ -550,9 +576,14 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
|
|||
display: "flex",
|
||||
gap: "8px",
|
||||
flexDirection:
|
||||
component.labelPosition === "top" || component.labelPosition === "bottom" ? "column" : "row",
|
||||
component.labelPosition === "top" || component.labelPosition === "bottom"
|
||||
? "column"
|
||||
: "row",
|
||||
...(component.labelPosition === "right" || component.labelPosition === "bottom"
|
||||
? { flexDirection: component.labelPosition === "right" ? "row-reverse" : "column-reverse" }
|
||||
? {
|
||||
flexDirection:
|
||||
component.labelPosition === "right" ? "row-reverse" : "column-reverse",
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
|
|
@ -665,6 +696,9 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
|
|||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} disabled={isExporting}>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
"use client";
|
||||
|
||||
import { createContext, useContext, useState, useCallback, ReactNode, useEffect } from "react";
|
||||
import { ComponentConfig, ReportDetail, ReportLayout } from "@/types/report";
|
||||
import { ComponentConfig, ReportDetail, ReportLayout, ReportPage, ReportLayoutConfig } from "@/types/report";
|
||||
import { reportApi } from "@/lib/api/reportApi";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
export interface ReportQuery {
|
||||
id: string;
|
||||
|
|
@ -26,7 +27,22 @@ interface ReportDesignerContextType {
|
|||
reportId: string;
|
||||
reportDetail: ReportDetail | null;
|
||||
layout: ReportLayout | null;
|
||||
components: ComponentConfig[];
|
||||
|
||||
// 페이지 관리
|
||||
layoutConfig: ReportLayoutConfig; // { pages: [...] }
|
||||
currentPageId: string | null;
|
||||
currentPage: ReportPage | undefined;
|
||||
|
||||
// 페이지 액션
|
||||
addPage: (name?: string) => void;
|
||||
deletePage: (pageId: string) => void;
|
||||
duplicatePage: (pageId: string) => void;
|
||||
reorderPages: (sourceIndex: number, targetIndex: number) => void;
|
||||
selectPage: (pageId: string) => void;
|
||||
updatePageSettings: (pageId: string, settings: Partial<ReportPage>) => void;
|
||||
|
||||
// 컴포넌트 (현재 페이지)
|
||||
components: ComponentConfig[]; // currentPage의 components (읽기 전용)
|
||||
selectedComponentId: string | null;
|
||||
selectedComponentIds: string[]; // 다중 선택된 컴포넌트 ID 배열
|
||||
isLoading: boolean;
|
||||
|
|
@ -128,7 +144,13 @@ const ReportDesignerContext = createContext<ReportDesignerContextType | undefine
|
|||
export function ReportDesignerProvider({ reportId, children }: { reportId: string; children: ReactNode }) {
|
||||
const [reportDetail, setReportDetail] = useState<ReportDetail | null>(null);
|
||||
const [layout, setLayout] = useState<ReportLayout | null>(null);
|
||||
const [components, setComponents] = useState<ComponentConfig[]>([]);
|
||||
|
||||
// 페이지 기반 레이아웃
|
||||
const [layoutConfig, setLayoutConfig] = useState<ReportLayoutConfig>({
|
||||
pages: [],
|
||||
});
|
||||
const [currentPageId, setCurrentPageId] = useState<string | null>(null);
|
||||
|
||||
const [queries, setQueries] = useState<ReportQuery[]>([]);
|
||||
const [queryResults, setQueryResults] = useState<QueryResult[]>([]);
|
||||
const [selectedComponentId, setSelectedComponentId] = useState<string | null>(null);
|
||||
|
|
@ -137,6 +159,31 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
|||
const [isSaving, setIsSaving] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
// 현재 페이지 계산
|
||||
const currentPage = layoutConfig.pages.find((p) => p.page_id === currentPageId);
|
||||
|
||||
// 현재 페이지의 컴포넌트 (읽기 전용)
|
||||
const components = currentPage?.components || [];
|
||||
|
||||
// 현재 페이지의 컴포넌트를 업데이트하는 헬퍼 함수
|
||||
const setComponents = useCallback(
|
||||
(updater: ComponentConfig[] | ((prev: ComponentConfig[]) => ComponentConfig[])) => {
|
||||
if (!currentPageId) return;
|
||||
|
||||
setLayoutConfig((prev) => ({
|
||||
pages: prev.pages.map((page) =>
|
||||
page.page_id === currentPageId
|
||||
? {
|
||||
...page,
|
||||
components: typeof updater === "function" ? updater(page.components) : updater,
|
||||
}
|
||||
: page,
|
||||
),
|
||||
}));
|
||||
},
|
||||
[currentPageId],
|
||||
);
|
||||
|
||||
// 레이아웃 도구 설정
|
||||
const [gridSize, setGridSize] = useState(10); // Grid Snap 크기 (px)
|
||||
const [showGrid, setShowGrid] = useState(true); // Grid 표시 여부
|
||||
|
|
@ -713,16 +760,16 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
|||
});
|
||||
}, [selectedComponentId, selectedComponentIds, components, toast]);
|
||||
|
||||
// 캔버스 설정 (기본값)
|
||||
const [canvasWidth, setCanvasWidth] = useState(210);
|
||||
const [canvasHeight, setCanvasHeight] = useState(297);
|
||||
const [pageOrientation, setPageOrientation] = useState("portrait");
|
||||
const [margins, setMargins] = useState({
|
||||
// 캔버스 설정 (현재 페이지 기반)
|
||||
const canvasWidth = currentPage?.width || 210;
|
||||
const canvasHeight = currentPage?.height || 297;
|
||||
const pageOrientation = currentPage?.orientation || "portrait";
|
||||
const margins = currentPage?.margins || {
|
||||
top: 20,
|
||||
bottom: 20,
|
||||
left: 20,
|
||||
right: 20,
|
||||
});
|
||||
};
|
||||
|
||||
// 정렬 가이드라인 계산 (캔버스 중앙선 포함)
|
||||
const calculateAlignmentGuides = useCallback(
|
||||
|
|
@ -793,12 +840,152 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
|||
setAlignmentGuides({ vertical: [], horizontal: [] });
|
||||
}, []);
|
||||
|
||||
// 페이지 관리 함수들
|
||||
const addPage = useCallback(
|
||||
(name?: string) => {
|
||||
const newPageId = uuidv4();
|
||||
const newPage: ReportPage = {
|
||||
page_id: newPageId,
|
||||
page_name: name || `페이지 ${layoutConfig.pages.length + 1}`,
|
||||
page_order: layoutConfig.pages.length,
|
||||
width: 210,
|
||||
height: 297,
|
||||
orientation: "portrait",
|
||||
margins: { top: 20, bottom: 20, left: 20, right: 20 },
|
||||
background_color: "#ffffff",
|
||||
components: [],
|
||||
};
|
||||
|
||||
setLayoutConfig((prev) => ({
|
||||
pages: [...prev.pages, newPage],
|
||||
}));
|
||||
|
||||
// 새 페이지로 자동 선택
|
||||
setCurrentPageId(newPageId);
|
||||
|
||||
toast({
|
||||
title: "페이지 추가",
|
||||
description: `${newPage.page_name}이(가) 추가되었습니다.`,
|
||||
});
|
||||
},
|
||||
[layoutConfig.pages.length, toast],
|
||||
);
|
||||
|
||||
const deletePage = useCallback(
|
||||
(pageId: string) => {
|
||||
if (layoutConfig.pages.length <= 1) {
|
||||
toast({
|
||||
title: "삭제 불가",
|
||||
description: "최소 1개의 페이지는 필요합니다.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const pageIndex = layoutConfig.pages.findIndex((p) => p.page_id === pageId);
|
||||
if (pageIndex === -1) return;
|
||||
|
||||
setLayoutConfig((prev) => ({
|
||||
pages: prev.pages.filter((p) => p.page_id !== pageId).map((p, idx) => ({ ...p, page_order: idx })), // 순서 재정렬
|
||||
}));
|
||||
|
||||
// 현재 페이지가 삭제된 경우 첫 번째 페이지로 이동
|
||||
if (currentPageId === pageId) {
|
||||
const remainingPages = layoutConfig.pages.filter((p) => p.page_id !== pageId);
|
||||
setCurrentPageId(remainingPages[0]?.page_id || null);
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "페이지 삭제",
|
||||
description: "페이지가 삭제되었습니다.",
|
||||
});
|
||||
},
|
||||
[layoutConfig.pages, currentPageId, toast],
|
||||
);
|
||||
|
||||
const duplicatePage = useCallback(
|
||||
(pageId: string) => {
|
||||
const sourcePage = layoutConfig.pages.find((p) => p.page_id === pageId);
|
||||
if (!sourcePage) return;
|
||||
|
||||
const newPageId = uuidv4();
|
||||
const newPage: ReportPage = {
|
||||
...sourcePage,
|
||||
page_id: newPageId,
|
||||
page_name: `${sourcePage.page_name} (복사)`,
|
||||
page_order: layoutConfig.pages.length,
|
||||
// 컴포넌트도 복제 (새로운 ID 부여)
|
||||
components: sourcePage.components.map((comp) => ({
|
||||
...comp,
|
||||
id: `comp_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
|
||||
})),
|
||||
};
|
||||
|
||||
setLayoutConfig((prev) => ({
|
||||
pages: [...prev.pages, newPage],
|
||||
}));
|
||||
|
||||
setCurrentPageId(newPageId);
|
||||
|
||||
toast({
|
||||
title: "페이지 복제",
|
||||
description: `${newPage.page_name}이(가) 생성되었습니다.`,
|
||||
});
|
||||
},
|
||||
[layoutConfig.pages, toast],
|
||||
);
|
||||
|
||||
const reorderPages = useCallback(
|
||||
(sourceIndex: number, targetIndex: number) => {
|
||||
if (sourceIndex === targetIndex) return;
|
||||
|
||||
const newPages = [...layoutConfig.pages];
|
||||
const [movedPage] = newPages.splice(sourceIndex, 1);
|
||||
newPages.splice(targetIndex, 0, movedPage);
|
||||
|
||||
// page_order 업데이트
|
||||
newPages.forEach((page, idx) => {
|
||||
page.page_order = idx;
|
||||
});
|
||||
|
||||
setLayoutConfig({ pages: newPages });
|
||||
},
|
||||
[layoutConfig.pages],
|
||||
);
|
||||
|
||||
const selectPage = useCallback((pageId: string) => {
|
||||
setCurrentPageId(pageId);
|
||||
// 페이지 전환 시 선택 해제
|
||||
setSelectedComponentId(null);
|
||||
setSelectedComponentIds([]);
|
||||
}, []);
|
||||
|
||||
const updatePageSettings = useCallback((pageId: string, settings: Partial<ReportPage>) => {
|
||||
setLayoutConfig((prev) => ({
|
||||
pages: prev.pages.map((page) => (page.page_id === pageId ? { ...page, ...settings } : page)),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 리포트 및 레이아웃 로드
|
||||
const loadLayout = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// 'new'는 새 리포트 생성 모드
|
||||
// 'new'는 새 리포트 생성 모드 - 기본 페이지 1개 생성
|
||||
if (reportId === "new") {
|
||||
const defaultPageId = uuidv4();
|
||||
const defaultPage: ReportPage = {
|
||||
page_id: defaultPageId,
|
||||
page_name: "페이지 1",
|
||||
page_order: 0,
|
||||
width: 210,
|
||||
height: 297,
|
||||
orientation: "portrait",
|
||||
margins: { top: 20, bottom: 20, left: 20, right: 20 },
|
||||
background_color: "#ffffff",
|
||||
components: [],
|
||||
};
|
||||
setLayoutConfig({ pages: [defaultPage] });
|
||||
setCurrentPageId(defaultPageId);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
|
@ -816,6 +1003,7 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
|||
type: q.query_type,
|
||||
sqlQuery: q.sql_query,
|
||||
parameters: Array.isArray(q.parameters) ? q.parameters : [],
|
||||
externalConnectionId: q.external_connection_id || undefined,
|
||||
}));
|
||||
setQueries(loadedQueries);
|
||||
}
|
||||
|
|
@ -827,23 +1015,70 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
|||
if (layoutResponse.success && layoutResponse.data) {
|
||||
const layoutData = layoutResponse.data;
|
||||
setLayout(layoutData);
|
||||
setComponents(layoutData.components || []);
|
||||
setCanvasWidth(layoutData.canvas_width);
|
||||
setCanvasHeight(layoutData.canvas_height);
|
||||
setPageOrientation(layoutData.page_orientation);
|
||||
setMargins({
|
||||
top: layoutData.margin_top,
|
||||
bottom: layoutData.margin_bottom,
|
||||
left: layoutData.margin_left,
|
||||
right: layoutData.margin_right,
|
||||
});
|
||||
|
||||
// 자동 마이그레이션: 기존 단일 페이지 구조 → 다중 페이지 구조
|
||||
const oldComponents = layoutData.components || [];
|
||||
|
||||
// 기존 구조 감지
|
||||
if (oldComponents.length > 0) {
|
||||
const migratedPageId = uuidv4();
|
||||
const migratedPage: ReportPage = {
|
||||
page_id: migratedPageId,
|
||||
page_name: "페이지 1",
|
||||
page_order: 0,
|
||||
width: layoutData.canvas_width || 210,
|
||||
height: layoutData.canvas_height || 297,
|
||||
orientation: (layoutData.page_orientation as "portrait" | "landscape") || "portrait",
|
||||
margins: {
|
||||
top: layoutData.margin_top || 20,
|
||||
bottom: layoutData.margin_bottom || 20,
|
||||
left: layoutData.margin_left || 20,
|
||||
right: layoutData.margin_right || 20,
|
||||
},
|
||||
background_color: "#ffffff",
|
||||
components: oldComponents,
|
||||
};
|
||||
|
||||
setLayoutConfig({ pages: [migratedPage] });
|
||||
setCurrentPageId(migratedPageId);
|
||||
|
||||
console.log("✅ 기존 레이아웃을 페이지 구조로 자동 마이그레이션", migratedPage);
|
||||
} else {
|
||||
// 빈 레이아웃 - 기본 페이지 생성
|
||||
const defaultPageId = uuidv4();
|
||||
const defaultPage: ReportPage = {
|
||||
page_id: defaultPageId,
|
||||
page_name: "페이지 1",
|
||||
page_order: 0,
|
||||
width: 210,
|
||||
height: 297,
|
||||
orientation: "portrait",
|
||||
margins: { top: 20, bottom: 20, left: 20, right: 20 },
|
||||
background_color: "#ffffff",
|
||||
components: [],
|
||||
};
|
||||
setLayoutConfig({ pages: [defaultPage] });
|
||||
setCurrentPageId(defaultPageId);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 레이아웃이 없으면 기본값 사용
|
||||
console.log("레이아웃 없음, 기본값 사용");
|
||||
// 레이아웃이 없으면 기본 페이지 생성
|
||||
const defaultPageId = uuidv4();
|
||||
const defaultPage: ReportPage = {
|
||||
page_id: defaultPageId,
|
||||
page_name: "페이지 1",
|
||||
page_order: 0,
|
||||
width: 210,
|
||||
height: 297,
|
||||
orientation: "portrait",
|
||||
margins: { top: 20, bottom: 20, left: 20, right: 20 },
|
||||
background_color: "#ffffff",
|
||||
components: [],
|
||||
};
|
||||
setLayoutConfig({ pages: [defaultPage] });
|
||||
setCurrentPageId(defaultPageId);
|
||||
console.log("레이아웃 없음, 기본 페이지 생성");
|
||||
}
|
||||
|
||||
// 쿼리 조회는 이미 위에서 처리됨 (reportResponse.data.queries)
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "리포트를 불러오는데 실패했습니다.";
|
||||
toast({
|
||||
|
|
@ -912,24 +1147,59 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
|||
);
|
||||
|
||||
// 컴포넌트 추가
|
||||
const addComponent = useCallback((component: ComponentConfig) => {
|
||||
setComponents((prev) => [...prev, component]);
|
||||
}, []);
|
||||
// 컴포넌트 추가 (현재 페이지에)
|
||||
const addComponent = useCallback(
|
||||
(component: ComponentConfig) => {
|
||||
if (!currentPageId) return;
|
||||
|
||||
// 컴포넌트 업데이트
|
||||
const updateComponent = useCallback((id: string, updates: Partial<ComponentConfig>) => {
|
||||
setComponents((prev) => prev.map((comp) => (comp.id === id ? { ...comp, ...updates } : comp)));
|
||||
}, []);
|
||||
setLayoutConfig((prev) => ({
|
||||
pages: prev.pages.map((page) =>
|
||||
page.page_id === currentPageId ? { ...page, components: [...page.components, component] } : page,
|
||||
),
|
||||
}));
|
||||
},
|
||||
[currentPageId],
|
||||
);
|
||||
|
||||
// 컴포넌트 삭제
|
||||
// 컴포넌트 업데이트 (현재 페이지에서)
|
||||
const updateComponent = useCallback(
|
||||
(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)),
|
||||
}
|
||||
: page,
|
||||
),
|
||||
}));
|
||||
},
|
||||
[currentPageId],
|
||||
);
|
||||
|
||||
// 컴포넌트 삭제 (현재 페이지에서)
|
||||
const removeComponent = useCallback(
|
||||
(id: string) => {
|
||||
setComponents((prev) => prev.filter((comp) => comp.id !== id));
|
||||
if (!currentPageId) return;
|
||||
|
||||
setLayoutConfig((prev) => ({
|
||||
pages: prev.pages.map((page) =>
|
||||
page.page_id === currentPageId
|
||||
? { ...page, components: page.components.filter((comp) => comp.id !== id) }
|
||||
: page,
|
||||
),
|
||||
}));
|
||||
|
||||
if (selectedComponentId === id) {
|
||||
setSelectedComponentId(null);
|
||||
}
|
||||
// 다중 선택에서도 제거
|
||||
setSelectedComponentIds((prev) => prev.filter((compId) => compId !== id));
|
||||
},
|
||||
[selectedComponentId],
|
||||
[currentPageId, selectedComponentId],
|
||||
);
|
||||
|
||||
// 컴포넌트 선택 (단일/다중)
|
||||
|
|
@ -963,26 +1233,39 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
|||
}, []);
|
||||
|
||||
// 레이아웃 업데이트
|
||||
const updateLayout = useCallback((updates: Partial<ReportLayout>) => {
|
||||
const updateLayout = useCallback(
|
||||
(updates: Partial<ReportLayout>) => {
|
||||
setLayout((prev) => (prev ? { ...prev, ...updates } : null));
|
||||
|
||||
if (updates.canvas_width !== undefined) setCanvasWidth(updates.canvas_width);
|
||||
if (updates.canvas_height !== undefined) setCanvasHeight(updates.canvas_height);
|
||||
if (updates.page_orientation !== undefined) setPageOrientation(updates.page_orientation);
|
||||
// 현재 페이지 설정 업데이트
|
||||
if (!currentPageId) return;
|
||||
|
||||
const pageUpdates: Partial<ReportPage> = {};
|
||||
if (updates.canvas_width !== undefined) pageUpdates.width = updates.canvas_width;
|
||||
if (updates.canvas_height !== undefined) pageUpdates.height = updates.canvas_height;
|
||||
if (updates.page_orientation !== undefined)
|
||||
pageUpdates.orientation = updates.page_orientation as "portrait" | "landscape";
|
||||
|
||||
if (
|
||||
updates.margin_top !== undefined ||
|
||||
updates.margin_bottom !== undefined ||
|
||||
updates.margin_left !== undefined ||
|
||||
updates.margin_right !== undefined
|
||||
) {
|
||||
setMargins((prev) => ({
|
||||
top: updates.margin_top ?? prev.top,
|
||||
bottom: updates.margin_bottom ?? prev.bottom,
|
||||
left: updates.margin_left ?? prev.left,
|
||||
right: updates.margin_right ?? prev.right,
|
||||
}));
|
||||
pageUpdates.margins = {
|
||||
top: updates.margin_top ?? currentPage?.margins.top ?? 20,
|
||||
bottom: updates.margin_bottom ?? currentPage?.margins.bottom ?? 20,
|
||||
left: updates.margin_left ?? currentPage?.margins.left ?? 20,
|
||||
right: updates.margin_right ?? currentPage?.margins.right ?? 20,
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (Object.keys(pageUpdates).length > 0) {
|
||||
updatePageSettings(currentPageId, pageUpdates);
|
||||
}
|
||||
},
|
||||
[currentPageId, currentPage, updatePageSettings],
|
||||
);
|
||||
|
||||
// 레이아웃 저장
|
||||
const saveLayout = useCallback(async () => {
|
||||
|
|
@ -1008,17 +1291,13 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
|||
window.history.replaceState({}, "", `/admin/report/designer/${actualReportId}`);
|
||||
}
|
||||
|
||||
// 레이아웃 저장 (쿼리 포함)
|
||||
// 레이아웃 저장 (페이지 구조로)
|
||||
await reportApi.saveLayout(actualReportId, {
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
pageOrientation,
|
||||
marginTop: margins.top,
|
||||
marginBottom: margins.bottom,
|
||||
marginLeft: margins.left,
|
||||
marginRight: margins.right,
|
||||
components,
|
||||
queries,
|
||||
layoutConfig, // 페이지 기반 구조
|
||||
queries: queries.map((q) => ({
|
||||
...q,
|
||||
externalConnectionId: q.externalConnectionId || undefined,
|
||||
})),
|
||||
});
|
||||
|
||||
toast({
|
||||
|
|
@ -1040,7 +1319,7 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
|||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [reportId, canvasWidth, canvasHeight, pageOrientation, margins, components, queries, toast, loadLayout]);
|
||||
}, [reportId, layoutConfig, queries, toast, loadLayout]);
|
||||
|
||||
// 템플릿 적용
|
||||
const applyTemplate = useCallback(
|
||||
|
|
@ -1158,6 +1437,19 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
|||
reportId,
|
||||
reportDetail,
|
||||
layout,
|
||||
|
||||
// 페이지 관리
|
||||
layoutConfig,
|
||||
currentPageId,
|
||||
currentPage,
|
||||
addPage,
|
||||
deletePage,
|
||||
duplicatePage,
|
||||
reorderPages,
|
||||
selectPage,
|
||||
updatePageSettings,
|
||||
|
||||
// 컴포넌트 (현재 페이지)
|
||||
components,
|
||||
queries,
|
||||
setQueries,
|
||||
|
|
|
|||
|
|
@ -80,6 +80,29 @@ export interface ExternalConnection {
|
|||
is_active: string;
|
||||
}
|
||||
|
||||
// 페이지 설정
|
||||
export interface ReportPage {
|
||||
page_id: string;
|
||||
page_name: string;
|
||||
page_order: number;
|
||||
width: number; // mm
|
||||
height: number; // mm
|
||||
orientation: "portrait" | "landscape";
|
||||
margins: {
|
||||
top: number;
|
||||
bottom: number;
|
||||
left: number;
|
||||
right: number;
|
||||
};
|
||||
background_color: string;
|
||||
components: ComponentConfig[];
|
||||
}
|
||||
|
||||
// 레이아웃 설정 (페이지 기반)
|
||||
export interface ReportLayoutConfig {
|
||||
pages: ReportPage[];
|
||||
}
|
||||
|
||||
// 컴포넌트 설정
|
||||
export interface ComponentConfig {
|
||||
id: string;
|
||||
|
|
@ -183,21 +206,25 @@ export interface UpdateReportRequest {
|
|||
|
||||
// 레이아웃 저장 요청
|
||||
export interface SaveLayoutRequest {
|
||||
canvasWidth: number;
|
||||
canvasHeight: number;
|
||||
pageOrientation: string;
|
||||
marginTop: number;
|
||||
marginBottom: number;
|
||||
marginLeft: number;
|
||||
marginRight: number;
|
||||
components: ComponentConfig[];
|
||||
layoutConfig: ReportLayoutConfig; // 페이지 기반 구조
|
||||
queries?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
type: "MASTER" | "DETAIL";
|
||||
sqlQuery: string;
|
||||
parameters: string[];
|
||||
externalConnectionId?: number;
|
||||
}>;
|
||||
|
||||
// 하위 호환성 (deprecated)
|
||||
canvasWidth?: number;
|
||||
canvasHeight?: number;
|
||||
pageOrientation?: string;
|
||||
marginTop?: number;
|
||||
marginBottom?: number;
|
||||
marginLeft?: number;
|
||||
marginRight?: number;
|
||||
components?: ComponentConfig[];
|
||||
}
|
||||
|
||||
// 템플릿 목록 응답
|
||||
|
|
|
|||
Loading…
Reference in New Issue