페이지 관리 시스템 전체 구현
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 { DndProvider } from "react-dnd";
|
||||||
import { HTML5Backend } from "react-dnd-html5-backend";
|
import { HTML5Backend } from "react-dnd-html5-backend";
|
||||||
import { ReportDesignerToolbar } from "@/components/report/designer/ReportDesignerToolbar";
|
import { ReportDesignerToolbar } from "@/components/report/designer/ReportDesignerToolbar";
|
||||||
|
import { PageListPanel } from "@/components/report/designer/PageListPanel";
|
||||||
import { ReportDesignerLeftPanel } from "@/components/report/designer/ReportDesignerLeftPanel";
|
import { ReportDesignerLeftPanel } from "@/components/report/designer/ReportDesignerLeftPanel";
|
||||||
import { ReportDesignerCanvas } from "@/components/report/designer/ReportDesignerCanvas";
|
import { ReportDesignerCanvas } from "@/components/report/designer/ReportDesignerCanvas";
|
||||||
import { ReportDesignerRightPanel } from "@/components/report/designer/ReportDesignerRightPanel";
|
import { ReportDesignerRightPanel } from "@/components/report/designer/ReportDesignerRightPanel";
|
||||||
|
|
@ -72,13 +73,16 @@ export default function ReportDesignerPage() {
|
||||||
|
|
||||||
{/* 메인 영역 */}
|
{/* 메인 영역 */}
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
{/* 좌측 패널 */}
|
{/* 페이지 목록 패널 */}
|
||||||
|
<PageListPanel />
|
||||||
|
|
||||||
|
{/* 좌측 패널 (템플릿, 컴포넌트) */}
|
||||||
<ReportDesignerLeftPanel />
|
<ReportDesignerLeftPanel />
|
||||||
|
|
||||||
{/* 중앙 캔버스 */}
|
{/* 중앙 캔버스 */}
|
||||||
<ReportDesignerCanvas />
|
<ReportDesignerCanvas />
|
||||||
|
|
||||||
{/* 우측 패널 */}
|
{/* 우측 패널 (속성) */}
|
||||||
<ReportDesignerRightPanel />
|
<ReportDesignerRightPanel />
|
||||||
</div>
|
</div>
|
||||||
</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() {
|
export function ReportDesignerCanvas() {
|
||||||
const canvasRef = useRef<HTMLDivElement>(null);
|
const canvasRef = useRef<HTMLDivElement>(null);
|
||||||
const {
|
const {
|
||||||
|
currentPageId,
|
||||||
|
currentPage,
|
||||||
components,
|
components,
|
||||||
addComponent,
|
addComponent,
|
||||||
updateComponent,
|
updateComponent,
|
||||||
|
|
@ -259,10 +261,24 @@ export function ReportDesignerCanvas() {
|
||||||
redo,
|
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 (
|
return (
|
||||||
<div className="flex flex-1 flex-col overflow-hidden bg-gray-100">
|
<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">
|
<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() {
|
export function ReportDesignerRightPanel() {
|
||||||
const context = useReportDesigner();
|
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 [activeTab, setActiveTab] = useState<string>("properties");
|
||||||
const [uploadingImage, setUploadingImage] = useState(false);
|
const [uploadingImage, setUploadingImage] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
@ -91,13 +100,17 @@ export function ReportDesignerRightPanel() {
|
||||||
<div className="w-[450px] border-l bg-white">
|
<div className="w-[450px] border-l bg-white">
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="h-full">
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="h-full">
|
||||||
<div className="border-b p-2">
|
<div className="border-b p-2">
|
||||||
<TabsList className="grid w-full grid-cols-2">
|
<TabsList className="grid w-full grid-cols-3">
|
||||||
<TabsTrigger value="properties" className="gap-2">
|
<TabsTrigger value="page" className="gap-1 text-xs">
|
||||||
<Settings className="h-4 w-4" />
|
<Settings className="h-3 w-3" />
|
||||||
|
페이지
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="properties" className="gap-1 text-xs">
|
||||||
|
<Settings className="h-3 w-3" />
|
||||||
속성
|
속성
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="queries" className="gap-2">
|
<TabsTrigger value="queries" className="gap-1 text-xs">
|
||||||
<Database className="h-4 w-4" />
|
<Database className="h-3 w-3" />
|
||||||
쿼리
|
쿼리
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
@ -1114,6 +1127,296 @@ export function ReportDesignerRightPanel() {
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</TabsContent>
|
</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)]">
|
<TabsContent value="queries" className="mt-0 h-[calc(100vh-120px)]">
|
||||||
<QueryManager />
|
<QueryManager />
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ interface ReportPreviewModalProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ReportPreviewModal({ isOpen, onClose }: 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 [isExporting, setIsExporting] = useState(false);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
|
@ -53,10 +53,14 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
|
||||||
printWindow.print();
|
printWindow.print();
|
||||||
};
|
};
|
||||||
|
|
||||||
// HTML 생성 (인쇄/PDF용)
|
// 페이지별 컴포넌트 HTML 생성
|
||||||
const generatePrintHTML = (): string => {
|
const generatePageHTML = (
|
||||||
// 컴포넌트별 HTML 생성
|
pageComponents: any[],
|
||||||
const componentsHTML = components
|
pageWidth: number,
|
||||||
|
pageHeight: number,
|
||||||
|
backgroundColor: string,
|
||||||
|
): string => {
|
||||||
|
const componentsHTML = pageComponents
|
||||||
.map((component) => {
|
.map((component) => {
|
||||||
const queryResult = component.queryId ? getQueryResult(component.queryId) : null;
|
const queryResult = component.queryId ? getQueryResult(component.queryId) : null;
|
||||||
let content = "";
|
let content = "";
|
||||||
|
|
@ -152,7 +156,7 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
|
||||||
.map(
|
.map(
|
||||||
(row) => `
|
(row) => `
|
||||||
<tr>
|
<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>
|
</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;">
|
<table style="width: 100%; border-collapse: ${component.showBorder !== false ? "collapse" : "separate"}; font-size: 12px;">
|
||||||
<thead>
|
<thead>
|
||||||
<tr style="background-color: ${component.headerBackgroundColor || "#f3f4f6"}; color: ${component.headerTextColor || "#111827"};">
|
<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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|
@ -178,6 +182,19 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
|
||||||
})
|
})
|
||||||
.join("");
|
.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 `
|
return `
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
|
|
@ -191,7 +208,8 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
|
||||||
}
|
}
|
||||||
@media print {
|
@media print {
|
||||||
body { margin: 0; padding: 0; }
|
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 {
|
body {
|
||||||
font-family: 'Malgun Gothic', 'Apple SD Gothic Neo', sans-serif;
|
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;
|
-webkit-print-color-adjust: exact;
|
||||||
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="print-container">
|
${pagesHTML}
|
||||||
${componentsHTML}
|
|
||||||
</div>
|
|
||||||
<script>
|
<script>
|
||||||
window.onload = function() {
|
window.onload = function() {
|
||||||
// 이미지 로드 대기 후 인쇄
|
// 이미지 로드 대기 후 인쇄
|
||||||
|
|
@ -266,8 +275,13 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
|
||||||
// 컴포넌트를 Paragraph로 변환
|
// 컴포넌트를 Paragraph로 변환
|
||||||
const paragraphs: (Paragraph | Table)[] = [];
|
const paragraphs: (Paragraph | Table)[] = [];
|
||||||
|
|
||||||
|
// 모든 페이지의 컴포넌트 수집
|
||||||
|
const allComponents = layoutConfig.pages
|
||||||
|
.sort((a, b) => a.page_order - b.page_order)
|
||||||
|
.flatMap((page) => page.components);
|
||||||
|
|
||||||
// Y 좌표로 정렬
|
// 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) {
|
for (const component of sortedComponents) {
|
||||||
if (component.type === "text" || component.type === "label") {
|
if (component.type === "text" || component.type === "label") {
|
||||||
|
|
@ -370,299 +384,319 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{/* 미리보기 영역 */}
|
{/* 미리보기 영역 - 모든 페이지 표시 */}
|
||||||
<div className="max-h-[500px] overflow-auto rounded border bg-gray-100 p-4">
|
<div className="max-h-[500px] overflow-auto rounded border bg-gray-100 p-4">
|
||||||
<div
|
<div className="space-y-4">
|
||||||
id="preview-content"
|
{layoutConfig.pages
|
||||||
className="relative mx-auto bg-white shadow-lg"
|
.sort((a, b) => a.page_order - b.page_order)
|
||||||
style={{
|
.map((page) => (
|
||||||
width: `${canvasWidth}mm`,
|
<div key={page.page_id} className="relative">
|
||||||
minHeight: `${canvasHeight}mm`,
|
{/* 페이지 번호 라벨 */}
|
||||||
}}
|
<div className="mb-2 text-center text-xs text-gray-500">
|
||||||
>
|
페이지 {page.page_order + 1} - {page.page_name}
|
||||||
{components.map((component) => {
|
</div>
|
||||||
const displayValue = getComponentValue(component);
|
|
||||||
const queryResult = component.queryId ? getQueryResult(component.queryId) : null;
|
|
||||||
|
|
||||||
return (
|
{/* 페이지 컨텐츠 */}
|
||||||
<div
|
<div
|
||||||
key={component.id}
|
className="relative mx-auto shadow-lg"
|
||||||
className="absolute"
|
style={{
|
||||||
style={{
|
width: `${page.width}mm`,
|
||||||
left: `${component.x}px`,
|
minHeight: `${page.height}mm`,
|
||||||
top: `${component.y}px`,
|
backgroundColor: page.background_color,
|
||||||
width: `${component.width}px`,
|
}}
|
||||||
height: `${component.height}px`,
|
>
|
||||||
backgroundColor: component.backgroundColor,
|
{page.components.map((component) => {
|
||||||
border: component.borderWidth
|
const displayValue = getComponentValue(component);
|
||||||
? `${component.borderWidth}px solid ${component.borderColor}`
|
const queryResult = component.queryId ? getQueryResult(component.queryId) : null;
|
||||||
: "none",
|
|
||||||
padding: "8px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{component.type === "text" && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: `${component.fontSize}px`,
|
|
||||||
color: component.fontColor,
|
|
||||||
fontWeight: component.fontWeight,
|
|
||||||
textAlign: component.textAlign as "left" | "center" | "right",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{displayValue}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{component.type === "label" && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: `${component.fontSize}px`,
|
|
||||||
color: component.fontColor,
|
|
||||||
fontWeight: component.fontWeight,
|
|
||||||
textAlign: component.textAlign as "left" | "center" | "right",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{displayValue}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{component.type === "table" && queryResult && queryResult.rows.length > 0 ? (
|
|
||||||
(() => {
|
|
||||||
// tableColumns가 없으면 자동 생성
|
|
||||||
const columns =
|
|
||||||
component.tableColumns && component.tableColumns.length > 0
|
|
||||||
? component.tableColumns
|
|
||||||
: queryResult.fields.map((field) => ({
|
|
||||||
field,
|
|
||||||
header: field,
|
|
||||||
align: "left" as const,
|
|
||||||
width: undefined,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<table
|
<div
|
||||||
|
key={component.id}
|
||||||
|
className="absolute"
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
left: `${component.x}px`,
|
||||||
borderCollapse: component.showBorder !== false ? "collapse" : "separate",
|
top: `${component.y}px`,
|
||||||
fontSize: "12px",
|
width: `${component.width}px`,
|
||||||
|
height: `${component.height}px`,
|
||||||
|
backgroundColor: component.backgroundColor,
|
||||||
|
border: component.borderWidth
|
||||||
|
? `${component.borderWidth}px solid ${component.borderColor}`
|
||||||
|
: "none",
|
||||||
|
padding: "8px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<thead>
|
{component.type === "text" && (
|
||||||
<tr
|
<div
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: component.headerBackgroundColor || "#f3f4f6",
|
fontSize: `${component.fontSize}px`,
|
||||||
color: component.headerTextColor || "#111827",
|
color: component.fontColor,
|
||||||
|
fontWeight: component.fontWeight,
|
||||||
|
textAlign: component.textAlign as "left" | "center" | "right",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{columns.map((col) => (
|
{displayValue}
|
||||||
<th
|
</div>
|
||||||
key={col.field}
|
)}
|
||||||
|
|
||||||
|
{component.type === "label" && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: `${component.fontSize}px`,
|
||||||
|
color: component.fontColor,
|
||||||
|
fontWeight: component.fontWeight,
|
||||||
|
textAlign: component.textAlign as "left" | "center" | "right",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{displayValue}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{component.type === "table" && queryResult && queryResult.rows.length > 0 ? (
|
||||||
|
(() => {
|
||||||
|
// tableColumns가 없으면 자동 생성
|
||||||
|
const columns =
|
||||||
|
component.tableColumns && component.tableColumns.length > 0
|
||||||
|
? component.tableColumns
|
||||||
|
: queryResult.fields.map((field) => ({
|
||||||
|
field,
|
||||||
|
header: field,
|
||||||
|
align: "left" as const,
|
||||||
|
width: undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<table
|
||||||
style={{
|
style={{
|
||||||
border: component.showBorder !== false ? "1px solid #d1d5db" : "none",
|
width: "100%",
|
||||||
padding: "6px 8px",
|
borderCollapse: component.showBorder !== false ? "collapse" : "separate",
|
||||||
textAlign: col.align || "left",
|
fontSize: "12px",
|
||||||
width: col.width ? `${col.width}px` : "auto",
|
|
||||||
fontWeight: "600",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{col.header}
|
<thead>
|
||||||
</th>
|
<tr
|
||||||
))}
|
style={{
|
||||||
</tr>
|
backgroundColor: component.headerBackgroundColor || "#f3f4f6",
|
||||||
</thead>
|
color: component.headerTextColor || "#111827",
|
||||||
<tbody>
|
}}
|
||||||
{queryResult.rows.map((row, idx) => (
|
>
|
||||||
<tr key={idx}>
|
{columns.map((col) => (
|
||||||
{columns.map((col) => (
|
<th
|
||||||
<td
|
key={col.field}
|
||||||
key={col.field}
|
style={{
|
||||||
style={{
|
border: component.showBorder !== false ? "1px solid #d1d5db" : "none",
|
||||||
border: component.showBorder !== false ? "1px solid #d1d5db" : "none",
|
padding: "6px 8px",
|
||||||
padding: "6px 8px",
|
textAlign: col.align || "left",
|
||||||
textAlign: col.align || "left",
|
width: col.width ? `${col.width}px` : "auto",
|
||||||
height: component.rowHeight ? `${component.rowHeight}px` : "auto",
|
fontWeight: "600",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{String(row[col.field] ?? "")}
|
{col.header}
|
||||||
</td>
|
</th>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
</thead>
|
||||||
</tbody>
|
<tbody>
|
||||||
</table>
|
{queryResult.rows.map((row, idx) => (
|
||||||
);
|
<tr key={idx}>
|
||||||
})()
|
{columns.map((col) => (
|
||||||
) : component.type === "table" ? (
|
<td
|
||||||
<div className="text-xs text-gray-400">쿼리를 실행해주세요</div>
|
key={col.field}
|
||||||
) : null}
|
style={{
|
||||||
|
border: component.showBorder !== false ? "1px solid #d1d5db" : "none",
|
||||||
|
padding: "6px 8px",
|
||||||
|
textAlign: col.align || "left",
|
||||||
|
height: component.rowHeight ? `${component.rowHeight}px` : "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{String(row[col.field] ?? "")}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
})()
|
||||||
|
) : component.type === "table" ? (
|
||||||
|
<div className="text-xs text-gray-400">쿼리를 실행해주세요</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{component.type === "image" && component.imageUrl && (
|
{component.type === "image" && component.imageUrl && (
|
||||||
<img
|
<img
|
||||||
src={getFullImageUrl(component.imageUrl)}
|
src={getFullImageUrl(component.imageUrl)}
|
||||||
alt="이미지"
|
alt="이미지"
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
objectFit: component.objectFit || "contain",
|
objectFit: component.objectFit || "contain",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{component.type === "divider" && (
|
{component.type === "divider" && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
width: component.orientation === "horizontal" ? "100%" : `${component.lineWidth || 1}px`,
|
width:
|
||||||
height: component.orientation === "vertical" ? "100%" : `${component.lineWidth || 1}px`,
|
component.orientation === "horizontal" ? "100%" : `${component.lineWidth || 1}px`,
|
||||||
backgroundColor: component.lineColor || "#000000",
|
height: component.orientation === "vertical" ? "100%" : `${component.lineWidth || 1}px`,
|
||||||
...(component.lineStyle === "dashed" && {
|
backgroundColor: component.lineColor || "#000000",
|
||||||
backgroundImage: `repeating-linear-gradient(
|
...(component.lineStyle === "dashed" && {
|
||||||
|
backgroundImage: `repeating-linear-gradient(
|
||||||
${component.orientation === "horizontal" ? "90deg" : "0deg"},
|
${component.orientation === "horizontal" ? "90deg" : "0deg"},
|
||||||
${component.lineColor || "#000000"} 0px,
|
${component.lineColor || "#000000"} 0px,
|
||||||
${component.lineColor || "#000000"} 10px,
|
${component.lineColor || "#000000"} 10px,
|
||||||
transparent 10px,
|
transparent 10px,
|
||||||
transparent 20px
|
transparent 20px
|
||||||
)`,
|
)`,
|
||||||
backgroundColor: "transparent",
|
backgroundColor: "transparent",
|
||||||
}),
|
}),
|
||||||
...(component.lineStyle === "dotted" && {
|
...(component.lineStyle === "dotted" && {
|
||||||
backgroundImage: `repeating-linear-gradient(
|
backgroundImage: `repeating-linear-gradient(
|
||||||
${component.orientation === "horizontal" ? "90deg" : "0deg"},
|
${component.orientation === "horizontal" ? "90deg" : "0deg"},
|
||||||
${component.lineColor || "#000000"} 0px,
|
${component.lineColor || "#000000"} 0px,
|
||||||
${component.lineColor || "#000000"} 3px,
|
${component.lineColor || "#000000"} 3px,
|
||||||
transparent 3px,
|
transparent 3px,
|
||||||
transparent 10px
|
transparent 10px
|
||||||
)`,
|
)`,
|
||||||
backgroundColor: "transparent",
|
backgroundColor: "transparent",
|
||||||
}),
|
}),
|
||||||
...(component.lineStyle === "double" && {
|
...(component.lineStyle === "double" && {
|
||||||
boxShadow:
|
boxShadow:
|
||||||
component.orientation === "horizontal"
|
component.orientation === "horizontal"
|
||||||
? `0 ${(component.lineWidth || 1) * 2}px 0 0 ${component.lineColor || "#000000"}`
|
? `0 ${(component.lineWidth || 1) * 2}px 0 0 ${component.lineColor || "#000000"}`
|
||||||
: `${(component.lineWidth || 1) * 2}px 0 0 0 ${component.lineColor || "#000000"}`,
|
: `${(component.lineWidth || 1) * 2}px 0 0 0 ${component.lineColor || "#000000"}`,
|
||||||
}),
|
}),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{component.type === "signature" && (
|
{component.type === "signature" && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
gap: "8px",
|
gap: "8px",
|
||||||
flexDirection:
|
flexDirection:
|
||||||
component.labelPosition === "top" || component.labelPosition === "bottom" ? "column" : "row",
|
component.labelPosition === "top" || component.labelPosition === "bottom"
|
||||||
...(component.labelPosition === "right" || component.labelPosition === "bottom"
|
? "column"
|
||||||
? { flexDirection: component.labelPosition === "right" ? "row-reverse" : "column-reverse" }
|
: "row",
|
||||||
: {}),
|
...(component.labelPosition === "right" || component.labelPosition === "bottom"
|
||||||
}}
|
? {
|
||||||
>
|
flexDirection:
|
||||||
{component.showLabel !== false && (
|
component.labelPosition === "right" ? "row-reverse" : "column-reverse",
|
||||||
<div
|
}
|
||||||
style={{
|
: {}),
|
||||||
display: "flex",
|
}}
|
||||||
alignItems: "center",
|
>
|
||||||
justifyContent: "center",
|
{component.showLabel !== false && (
|
||||||
fontSize: "12px",
|
<div
|
||||||
fontWeight: "500",
|
style={{
|
||||||
minWidth:
|
display: "flex",
|
||||||
component.labelPosition === "left" || component.labelPosition === "right"
|
alignItems: "center",
|
||||||
? "40px"
|
justifyContent: "center",
|
||||||
: "auto",
|
fontSize: "12px",
|
||||||
}}
|
fontWeight: "500",
|
||||||
>
|
minWidth:
|
||||||
{component.labelText || "서명:"}
|
component.labelPosition === "left" || component.labelPosition === "right"
|
||||||
</div>
|
? "40px"
|
||||||
)}
|
: "auto",
|
||||||
<div style={{ flex: 1, position: "relative" }}>
|
}}
|
||||||
{component.imageUrl && (
|
>
|
||||||
<img
|
{component.labelText || "서명:"}
|
||||||
src={getFullImageUrl(component.imageUrl)}
|
</div>
|
||||||
alt="서명"
|
)}
|
||||||
style={{
|
<div style={{ flex: 1, position: "relative" }}>
|
||||||
width: "100%",
|
{component.imageUrl && (
|
||||||
height: "100%",
|
<img
|
||||||
objectFit: component.objectFit || "contain",
|
src={getFullImageUrl(component.imageUrl)}
|
||||||
}}
|
alt="서명"
|
||||||
/>
|
style={{
|
||||||
)}
|
width: "100%",
|
||||||
{component.showUnderline !== false && (
|
height: "100%",
|
||||||
<div
|
objectFit: component.objectFit || "contain",
|
||||||
style={{
|
}}
|
||||||
position: "absolute",
|
/>
|
||||||
bottom: "0",
|
)}
|
||||||
left: "0",
|
{component.showUnderline !== false && (
|
||||||
right: "0",
|
<div
|
||||||
borderBottom: "2px solid #000000",
|
style={{
|
||||||
}}
|
position: "absolute",
|
||||||
/>
|
bottom: "0",
|
||||||
)}
|
left: "0",
|
||||||
</div>
|
right: "0",
|
||||||
</div>
|
borderBottom: "2px solid #000000",
|
||||||
)}
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{component.type === "stamp" && (
|
{component.type === "stamp" && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
gap: "8px",
|
gap: "8px",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{component.personName && (
|
{component.personName && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
fontSize: "12px",
|
fontSize: "12px",
|
||||||
fontWeight: "500",
|
fontWeight: "500",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{component.personName}
|
{component.personName}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
flex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{component.imageUrl && (
|
||||||
|
<img
|
||||||
|
src={getFullImageUrl(component.imageUrl)}
|
||||||
|
alt="도장"
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
objectFit: component.objectFit || "contain",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{component.showLabel !== false && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "0",
|
||||||
|
left: "0",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
fontSize: "12px",
|
||||||
|
fontWeight: "500",
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{component.labelText || "(인)"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
);
|
||||||
<div
|
})}
|
||||||
style={{
|
</div>
|
||||||
position: "relative",
|
|
||||||
flex: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{component.imageUrl && (
|
|
||||||
<img
|
|
||||||
src={getFullImageUrl(component.imageUrl)}
|
|
||||||
alt="도장"
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
objectFit: component.objectFit || "contain",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{component.showLabel !== false && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: "0",
|
|
||||||
left: "0",
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
fontSize: "12px",
|
|
||||||
fontWeight: "500",
|
|
||||||
pointerEvents: "none",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{component.labelText || "(인)"}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { createContext, useContext, useState, useCallback, ReactNode, useEffect } from "react";
|
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 { reportApi } from "@/lib/api/reportApi";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
|
||||||
export interface ReportQuery {
|
export interface ReportQuery {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -26,7 +27,22 @@ interface ReportDesignerContextType {
|
||||||
reportId: string;
|
reportId: string;
|
||||||
reportDetail: ReportDetail | null;
|
reportDetail: ReportDetail | null;
|
||||||
layout: ReportLayout | 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;
|
selectedComponentId: string | null;
|
||||||
selectedComponentIds: string[]; // 다중 선택된 컴포넌트 ID 배열
|
selectedComponentIds: string[]; // 다중 선택된 컴포넌트 ID 배열
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
|
@ -128,7 +144,13 @@ const ReportDesignerContext = createContext<ReportDesignerContextType | undefine
|
||||||
export function ReportDesignerProvider({ reportId, children }: { reportId: string; children: ReactNode }) {
|
export function ReportDesignerProvider({ reportId, children }: { reportId: string; children: ReactNode }) {
|
||||||
const [reportDetail, setReportDetail] = useState<ReportDetail | null>(null);
|
const [reportDetail, setReportDetail] = useState<ReportDetail | null>(null);
|
||||||
const [layout, setLayout] = useState<ReportLayout | 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 [queries, setQueries] = useState<ReportQuery[]>([]);
|
||||||
const [queryResults, setQueryResults] = useState<QueryResult[]>([]);
|
const [queryResults, setQueryResults] = useState<QueryResult[]>([]);
|
||||||
const [selectedComponentId, setSelectedComponentId] = useState<string | null>(null);
|
const [selectedComponentId, setSelectedComponentId] = useState<string | null>(null);
|
||||||
|
|
@ -137,6 +159,31 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const { toast } = useToast();
|
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 [gridSize, setGridSize] = useState(10); // Grid Snap 크기 (px)
|
||||||
const [showGrid, setShowGrid] = useState(true); // Grid 표시 여부
|
const [showGrid, setShowGrid] = useState(true); // Grid 표시 여부
|
||||||
|
|
@ -713,16 +760,16 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
||||||
});
|
});
|
||||||
}, [selectedComponentId, selectedComponentIds, components, toast]);
|
}, [selectedComponentId, selectedComponentIds, components, toast]);
|
||||||
|
|
||||||
// 캔버스 설정 (기본값)
|
// 캔버스 설정 (현재 페이지 기반)
|
||||||
const [canvasWidth, setCanvasWidth] = useState(210);
|
const canvasWidth = currentPage?.width || 210;
|
||||||
const [canvasHeight, setCanvasHeight] = useState(297);
|
const canvasHeight = currentPage?.height || 297;
|
||||||
const [pageOrientation, setPageOrientation] = useState("portrait");
|
const pageOrientation = currentPage?.orientation || "portrait";
|
||||||
const [margins, setMargins] = useState({
|
const margins = currentPage?.margins || {
|
||||||
top: 20,
|
top: 20,
|
||||||
bottom: 20,
|
bottom: 20,
|
||||||
left: 20,
|
left: 20,
|
||||||
right: 20,
|
right: 20,
|
||||||
});
|
};
|
||||||
|
|
||||||
// 정렬 가이드라인 계산 (캔버스 중앙선 포함)
|
// 정렬 가이드라인 계산 (캔버스 중앙선 포함)
|
||||||
const calculateAlignmentGuides = useCallback(
|
const calculateAlignmentGuides = useCallback(
|
||||||
|
|
@ -793,12 +840,152 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
||||||
setAlignmentGuides({ vertical: [], horizontal: [] });
|
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 () => {
|
const loadLayout = useCallback(async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
// 'new'는 새 리포트 생성 모드
|
// 'new'는 새 리포트 생성 모드 - 기본 페이지 1개 생성
|
||||||
if (reportId === "new") {
|
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);
|
setIsLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -816,6 +1003,7 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
||||||
type: q.query_type,
|
type: q.query_type,
|
||||||
sqlQuery: q.sql_query,
|
sqlQuery: q.sql_query,
|
||||||
parameters: Array.isArray(q.parameters) ? q.parameters : [],
|
parameters: Array.isArray(q.parameters) ? q.parameters : [],
|
||||||
|
externalConnectionId: q.external_connection_id || undefined,
|
||||||
}));
|
}));
|
||||||
setQueries(loadedQueries);
|
setQueries(loadedQueries);
|
||||||
}
|
}
|
||||||
|
|
@ -827,23 +1015,70 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
||||||
if (layoutResponse.success && layoutResponse.data) {
|
if (layoutResponse.success && layoutResponse.data) {
|
||||||
const layoutData = layoutResponse.data;
|
const layoutData = layoutResponse.data;
|
||||||
setLayout(layoutData);
|
setLayout(layoutData);
|
||||||
setComponents(layoutData.components || []);
|
|
||||||
setCanvasWidth(layoutData.canvas_width);
|
// 자동 마이그레이션: 기존 단일 페이지 구조 → 다중 페이지 구조
|
||||||
setCanvasHeight(layoutData.canvas_height);
|
const oldComponents = layoutData.components || [];
|
||||||
setPageOrientation(layoutData.page_orientation);
|
|
||||||
setMargins({
|
// 기존 구조 감지
|
||||||
top: layoutData.margin_top,
|
if (oldComponents.length > 0) {
|
||||||
bottom: layoutData.margin_bottom,
|
const migratedPageId = uuidv4();
|
||||||
left: layoutData.margin_left,
|
const migratedPage: ReportPage = {
|
||||||
right: layoutData.margin_right,
|
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 {
|
} 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) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : "리포트를 불러오는데 실패했습니다.";
|
const errorMessage = error instanceof Error ? error.message : "리포트를 불러오는데 실패했습니다.";
|
||||||
toast({
|
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;
|
||||||
|
|
||||||
// 컴포넌트 업데이트
|
setLayoutConfig((prev) => ({
|
||||||
const updateComponent = useCallback((id: string, updates: Partial<ComponentConfig>) => {
|
pages: prev.pages.map((page) =>
|
||||||
setComponents((prev) => prev.map((comp) => (comp.id === id ? { ...comp, ...updates } : comp)));
|
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(
|
const removeComponent = useCallback(
|
||||||
(id: string) => {
|
(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) {
|
if (selectedComponentId === id) {
|
||||||
setSelectedComponentId(null);
|
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(
|
||||||
setLayout((prev) => (prev ? { ...prev, ...updates } : null));
|
(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 (!currentPageId) return;
|
||||||
if (updates.page_orientation !== undefined) setPageOrientation(updates.page_orientation);
|
|
||||||
if (
|
const pageUpdates: Partial<ReportPage> = {};
|
||||||
updates.margin_top !== undefined ||
|
if (updates.canvas_width !== undefined) pageUpdates.width = updates.canvas_width;
|
||||||
updates.margin_bottom !== undefined ||
|
if (updates.canvas_height !== undefined) pageUpdates.height = updates.canvas_height;
|
||||||
updates.margin_left !== undefined ||
|
if (updates.page_orientation !== undefined)
|
||||||
updates.margin_right !== undefined
|
pageUpdates.orientation = updates.page_orientation as "portrait" | "landscape";
|
||||||
) {
|
|
||||||
setMargins((prev) => ({
|
if (
|
||||||
top: updates.margin_top ?? prev.top,
|
updates.margin_top !== undefined ||
|
||||||
bottom: updates.margin_bottom ?? prev.bottom,
|
updates.margin_bottom !== undefined ||
|
||||||
left: updates.margin_left ?? prev.left,
|
updates.margin_left !== undefined ||
|
||||||
right: updates.margin_right ?? prev.right,
|
updates.margin_right !== undefined
|
||||||
}));
|
) {
|
||||||
}
|
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 () => {
|
const saveLayout = useCallback(async () => {
|
||||||
|
|
@ -1008,17 +1291,13 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
||||||
window.history.replaceState({}, "", `/admin/report/designer/${actualReportId}`);
|
window.history.replaceState({}, "", `/admin/report/designer/${actualReportId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 레이아웃 저장 (쿼리 포함)
|
// 레이아웃 저장 (페이지 구조로)
|
||||||
await reportApi.saveLayout(actualReportId, {
|
await reportApi.saveLayout(actualReportId, {
|
||||||
canvasWidth,
|
layoutConfig, // 페이지 기반 구조
|
||||||
canvasHeight,
|
queries: queries.map((q) => ({
|
||||||
pageOrientation,
|
...q,
|
||||||
marginTop: margins.top,
|
externalConnectionId: q.externalConnectionId || undefined,
|
||||||
marginBottom: margins.bottom,
|
})),
|
||||||
marginLeft: margins.left,
|
|
||||||
marginRight: margins.right,
|
|
||||||
components,
|
|
||||||
queries,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
|
|
@ -1040,7 +1319,7 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
}, [reportId, canvasWidth, canvasHeight, pageOrientation, margins, components, queries, toast, loadLayout]);
|
}, [reportId, layoutConfig, queries, toast, loadLayout]);
|
||||||
|
|
||||||
// 템플릿 적용
|
// 템플릿 적용
|
||||||
const applyTemplate = useCallback(
|
const applyTemplate = useCallback(
|
||||||
|
|
@ -1158,6 +1437,19 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
||||||
reportId,
|
reportId,
|
||||||
reportDetail,
|
reportDetail,
|
||||||
layout,
|
layout,
|
||||||
|
|
||||||
|
// 페이지 관리
|
||||||
|
layoutConfig,
|
||||||
|
currentPageId,
|
||||||
|
currentPage,
|
||||||
|
addPage,
|
||||||
|
deletePage,
|
||||||
|
duplicatePage,
|
||||||
|
reorderPages,
|
||||||
|
selectPage,
|
||||||
|
updatePageSettings,
|
||||||
|
|
||||||
|
// 컴포넌트 (현재 페이지)
|
||||||
components,
|
components,
|
||||||
queries,
|
queries,
|
||||||
setQueries,
|
setQueries,
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,29 @@ export interface ExternalConnection {
|
||||||
is_active: string;
|
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 {
|
export interface ComponentConfig {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -183,21 +206,25 @@ export interface UpdateReportRequest {
|
||||||
|
|
||||||
// 레이아웃 저장 요청
|
// 레이아웃 저장 요청
|
||||||
export interface SaveLayoutRequest {
|
export interface SaveLayoutRequest {
|
||||||
canvasWidth: number;
|
layoutConfig: ReportLayoutConfig; // 페이지 기반 구조
|
||||||
canvasHeight: number;
|
|
||||||
pageOrientation: string;
|
|
||||||
marginTop: number;
|
|
||||||
marginBottom: number;
|
|
||||||
marginLeft: number;
|
|
||||||
marginRight: number;
|
|
||||||
components: ComponentConfig[];
|
|
||||||
queries?: Array<{
|
queries?: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
type: "MASTER" | "DETAIL";
|
type: "MASTER" | "DETAIL";
|
||||||
sqlQuery: string;
|
sqlQuery: string;
|
||||||
parameters: 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