페이지 관리 시스템 전체 구현

This commit is contained in:
dohyeons 2025-10-02 13:44:16 +09:00
parent fdc476a9e0
commit c9c416d6fd
8 changed files with 1666 additions and 362 deletions

View File

@ -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 페이지 시스템

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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[];
} }
// 템플릿 목록 응답 // 템플릿 목록 응답