리포트 템플릿 저장 구현

This commit is contained in:
dohyeons
2025-10-01 15:03:52 +09:00
parent 2ee4dd0b58
commit 62d36abb65
9 changed files with 852 additions and 104 deletions

View File

@@ -302,6 +302,101 @@ export class ReportController {
}
}
/**
* 현재 리포트를 템플릿으로 저장
* POST /api/admin/reports/:reportId/save-as-template
*/
async saveAsTemplate(req: Request, res: Response, next: NextFunction) {
try {
const { reportId } = req.params;
const { templateNameKor, templateNameEng, description } = req.body;
const userId = (req as any).user?.userId || "SYSTEM";
// 필수 필드 검증
if (!templateNameKor) {
return res.status(400).json({
success: false,
message: "템플릿명은 필수입니다.",
});
}
const templateId = await reportService.saveAsTemplate(
reportId,
templateNameKor,
templateNameEng,
description,
userId
);
return res.status(201).json({
success: true,
data: {
templateId,
},
message: "템플릿이 저장되었습니다.",
});
} catch (error) {
return next(error);
}
}
/**
* 레이아웃 데이터로 직접 템플릿 생성 (리포트 저장 불필요)
* POST /api/admin/reports/templates/create-from-layout
*/
async createTemplateFromLayout(
req: Request,
res: Response,
next: NextFunction
) {
try {
const {
templateNameKor,
templateNameEng,
templateType,
description,
layoutConfig,
defaultQueries = [],
} = req.body;
const userId = (req as any).user?.userId || "SYSTEM";
// 필수 필드 검증
if (!templateNameKor) {
return res.status(400).json({
success: false,
message: "템플릿명은 필수입니다.",
});
}
if (!layoutConfig) {
return res.status(400).json({
success: false,
message: "레이아웃 설정은 필수입니다.",
});
}
const templateId = await reportService.createTemplateFromLayout(
templateNameKor,
templateNameEng,
templateType || "GENERAL",
description,
layoutConfig,
defaultQueries,
userId
);
return res.status(201).json({
success: true,
data: {
templateId,
},
message: "템플릿이 생성되었습니다.",
});
} catch (error) {
return next(error);
}
}
/**
* 템플릿 삭제
* DELETE /api/admin/reports/templates/:templateId

View File

@@ -19,6 +19,10 @@ router.get("/templates", (req, res, next) =>
router.post("/templates", (req, res, next) =>
reportController.createTemplate(req, res, next)
);
// 레이아웃 데이터로 직접 템플릿 생성 (리포트 저장 불필요)
router.post("/templates/create-from-layout", (req, res, next) =>
reportController.createTemplateFromLayout(req, res, next)
);
router.delete("/templates/:templateId", (req, res, next) =>
reportController.deleteTemplate(req, res, next)
);
@@ -38,6 +42,11 @@ router.post("/:reportId/copy", (req, res, next) =>
reportController.copyReport(req, res, next)
);
// 템플릿으로 저장
router.post("/:reportId/save-as-template", (req, res, next) =>
reportController.saveAsTemplate(req, res, next)
);
// 레이아웃 관련 라우트
router.get("/:reportId/layout", (req, res, next) =>
reportController.getLayout(req, res, next)

View File

@@ -821,6 +821,185 @@ export class ReportService {
const result = await query(deleteQuery, [templateId]);
return true;
}
/**
* 현재 리포트를 템플릿으로 저장
*/
async saveAsTemplate(
reportId: string,
templateNameKor: string,
templateNameEng: string | null | undefined,
description: string | null | undefined,
userId: string
): Promise<string> {
return transaction(async (client) => {
// 리포트 정보 조회
const reportQuery = `
SELECT report_type FROM report_master WHERE report_id = $1
`;
const reportResult = await client.query(reportQuery, [reportId]);
if (reportResult.rows.length === 0) {
throw new Error("리포트를 찾을 수 없습니다.");
}
const reportType = reportResult.rows[0].report_type;
// 레이아웃 조회
const layoutQuery = `
SELECT
canvas_width,
canvas_height,
page_orientation,
margin_top,
margin_bottom,
margin_left,
margin_right,
components
FROM report_layout
WHERE report_id = $1
`;
const layoutResult = await client.query(layoutQuery, [reportId]);
if (layoutResult.rows.length === 0) {
throw new Error("레이아웃을 찾을 수 없습니다.");
}
const layout = layoutResult.rows[0];
// 쿼리 조회
const queriesQuery = `
SELECT
query_name,
query_type,
sql_query,
parameters,
external_connection_id,
display_order
FROM report_query
WHERE report_id = $1
ORDER BY display_order
`;
const queriesResult = await client.query(queriesQuery, [reportId]);
// 레이아웃 설정 JSON 생성
const layoutConfig = {
width: layout.canvas_width,
height: layout.canvas_height,
orientation: layout.page_orientation,
margins: {
top: layout.margin_top,
bottom: layout.margin_bottom,
left: layout.margin_left,
right: layout.margin_right,
},
components: JSON.parse(layout.components || "[]"),
};
// 기본 쿼리 JSON 생성
const defaultQueries = queriesResult.rows.map((q) => ({
name: q.query_name,
type: q.query_type,
sqlQuery: q.sql_query,
parameters: Array.isArray(q.parameters) ? q.parameters : [],
externalConnectionId: q.external_connection_id,
displayOrder: q.display_order,
}));
// 템플릿 생성
const templateId = `TPL_${uuidv4().replace(/-/g, "").substring(0, 20)}`;
const insertQuery = `
INSERT INTO report_template (
template_id,
template_name_kor,
template_name_eng,
template_type,
is_system,
description,
layout_config,
default_queries,
use_yn,
sort_order,
created_by
) VALUES ($1, $2, $3, $4, 'N', $5, $6, $7, 'Y', 999, $8)
`;
await client.query(insertQuery, [
templateId,
templateNameKor,
templateNameEng || null,
reportType,
description || null,
JSON.stringify(layoutConfig),
JSON.stringify(defaultQueries),
userId,
]);
return templateId;
});
}
// 레이아웃 데이터로 직접 템플릿 생성 (리포트 저장 불필요)
async createTemplateFromLayout(
templateNameKor: string,
templateNameEng: string | null | undefined,
templateType: string,
description: string | null | undefined,
layoutConfig: {
width: number;
height: number;
orientation: string;
margins: {
top: number;
bottom: number;
left: number;
right: number;
};
components: any[];
},
defaultQueries: Array<{
name: string;
type: "MASTER" | "DETAIL";
sqlQuery: string;
parameters: string[];
externalConnectionId?: number | null;
displayOrder?: number;
}>,
userId: string
): Promise<string> {
const templateId = `TPL_${uuidv4().replace(/-/g, "").substring(0, 20)}`;
const insertQuery = `
INSERT INTO report_template (
template_id,
template_name_kor,
template_name_eng,
template_type,
is_system,
description,
layout_config,
default_queries,
use_yn,
sort_order,
created_by
) VALUES ($1, $2, $3, $4, 'N', $5, $6, $7, 'Y', 999, $8)
RETURNING template_id
`;
await query(insertQuery, [
templateId,
templateNameKor,
templateNameEng || null,
templateType,
description || null,
JSON.stringify(layoutConfig),
JSON.stringify(defaultQueries),
userId,
]);
return templateId;
}
}
export default new ReportService();

View File

@@ -119,95 +119,110 @@
- `frontend/components/report/designer/ReportPreviewModal.tsx`
### 9. 템플릿 시스템
- [x] 시스템 템플릿 적용 (발주서, 청구서, 기본)
- [x] 템플릿별 기본 컴포넌트 자동 배치
- [x] 템플릿별 기본 쿼리 자동 생성
- [x] 사용자 정의 템플릿 저장 기능
- [x] 사용자 정의 템플릿 목록 조회
- [x] 사용자 정의 템플릿 삭제
- [x] 사용자 정의 템플릿 적용 (백엔드 연동)
**파일**:
- `frontend/contexts/ReportDesignerContext.tsx` (템플릿 적용 로직)
- `frontend/components/report/designer/TemplatePalette.tsx`
- `frontend/components/report/designer/SaveAsTemplateModal.tsx`
- `backend-node/src/services/reportService.ts` (createTemplateFromLayout)
### 10. 외부 DB 연동
- [x] 쿼리별 외부 DB 연결 선택
- [x] 외부 DB 연결 목록 조회 API
- [x] 쿼리 실행 시 외부 DB 지원
- [x] 내부/외부 DB 선택 UI
**파일**:
- `frontend/components/report/designer/QueryManager.tsx`
- `backend-node/src/services/reportService.ts` (executeQuery with external DB)
### 11. 컴포넌트 스타일링
- [x] 폰트 크기 설정
- [x] 폰트 색상 설정 (컬러피커)
- [x] 폰트 굵기 (보통/굵게)
- [x] 텍스트 정렬 (좌/중/우)
- [x] 배경색 설정 (투명 옵션 포함)
- [x] 테두리 설정 (두께, 색상)
- [x] 캔버스 및 미리보기에 스타일 반영
**파일**:
- `frontend/components/report/designer/ReportDesignerRightPanel.tsx`
- `frontend/components/report/designer/CanvasComponent.tsx`
---
## 진행 중인 작업 🚧
### 템플릿 적용 기능 (현재 작업)
- [ ] 템플릿 선택 시 컴포넌트 자동 배치
- [ ] 템플릿별 기본 쿼리 생성
- [ ] 발주서 템플릿 구현
- [ ] 청구서 템플릿 구현
- [ ] 기본 템플릿 구현
없음 (현재 모든 핵심 기능 구현 완료)
---
## 남은 작업 (우선순위순) 📋
### Phase 1: 핵심 기능 완성
### Phase 1: 사용성 개선 (권장)
1. **템플릿 적용 기능** ⬅️ 다음 작업
1. **PDF/WORD 내보내기** ⬅️ 다음 권장 작업
- 템플릿 팔레트 클릭 이벤트 처리
- 템플릿별 레이아웃 정의
- 컴포넌트 자동 배치 로직
- 기본 쿼리 자동 생성
2. **스타일링 속성 추가**
- 폰트 크기, 색상, 굵기, 정렬
- 배경색, 테두리 (색상, 두께, 스타일)
- 패딩, 마진
- 조건부 서식 (선택사항)
3. **리포트 복사/삭제 기능 완성**
- 복사 기능 테스트 및 개선
- 삭제 확인 다이얼로그
- 삭제 API 연결
### Phase 2: 고급 기능
4. **사용자 정의 템플릿 저장**
- 현재 레이아웃을 템플릿으로 저장
- 템플릿 이름/설명 입력
- 템플릿 목록에 추가
- 시스템 템플릿과 구분
5. **외부 DB 연동**
- 외부 DB 연결 정보 관리
- 쿼리 실행 시 DB 선택
- 연결 테스트 기능
6. **PDF/WORD 내보내기**
- jsPDF 또는 pdfmake 라이브러리 사용
- HTML to DOCX 변환
- 다운로드 기능 구현
- 미리보기 모달에 버튼 추가
### Phase 3: 사용성 개선
7. **레이아웃 도구**
2. **레이아웃 도구**
- 격자 스냅 (Grid Snap)
- 정렬 가이드라인
- 컴포넌트 그룹화
- 실행 취소/다시 실행 (Undo/Redo)
8. **쿼리 관리 개선**
3. **쿼리 관리 개선**
- 쿼리 미리보기 개선 (테이블 형태)
- 쿼리 저장/불러오기
- 쿼리 템플릿
9. **성능 최적화**
### Phase 2: 추가 컴포넌트
4. **다양한 컴포넌트 추가**
- 이미지 컴포넌트
- 차트 컴포넌트 (막대, 선, 원형)
- 바코드/QR코드 (선택사항)
- 구분선 (Divider)
- 체크박스/라디오 버튼
5. **조건부 서식**
- 특정 조건에 따른 스타일 변경
- 값 범위에 따른 색상 표시
- 수식 기반 표시/숨김
### Phase 3: 성능 및 보안
6. **성능 최적화**
- 쿼리 결과 캐싱
- 대용량 데이터 페이징
- 렌더링 최적화
- 이미지 레이지 로딩
### Phase 4: 추가 기능
10. **다양한 컴포넌트 추가**
- 이미지 컴포넌트
- 차트 컴포넌트 (막대, 선, 원형)
- 바코드/QR코드 (선택사항)
11. **권한 관리**
- 리포트별 접근 권한
- 수정 권한 분리
- 템플릿 공유
7. **권한 관리**
- 리포트별 접근 권한
- 수정 권한 분리
- 템플릿 공유
- 사용자별 리포트 목록 필터링
---
@@ -300,4 +315,4 @@
**최종 업데이트**: 2025-10-01
**작성자**: AI Assistant
**상태**: 복사/삭제 기능 구현 완료, 테스트 대기 중 (75% 완료)
**상태**: 핵심 기능 구현 완료 (90% 완료)

View File

@@ -1,16 +1,26 @@
"use client";
import { Button } from "@/components/ui/button";
import { Save, Eye, RotateCcw, ArrowLeft, Loader2 } from "lucide-react";
import { Save, Eye, RotateCcw, ArrowLeft, Loader2, BookTemplate } from "lucide-react";
import { useRouter } from "next/navigation";
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
import { useState } from "react";
import { SaveAsTemplateModal } from "./SaveAsTemplateModal";
import { reportApi } from "@/lib/api/reportApi";
import { useToast } from "@/hooks/use-toast";
import { ReportPreviewModal } from "./ReportPreviewModal";
export function ReportDesignerToolbar() {
const router = useRouter();
const { reportDetail, saveLayout, isSaving, loadLayout } = useReportDesigner();
const { reportDetail, saveLayout, isSaving, loadLayout, components, canvasWidth, canvasHeight, queries } =
useReportDesigner();
const [showPreview, setShowPreview] = useState(false);
const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false);
const { toast } = useToast();
// 템플릿 저장 가능 여부: 컴포넌트가 있어야 함
const canSaveAsTemplate = components.length > 0;
const handleSave = async () => {
await saveLayout();
@@ -33,6 +43,63 @@ export function ReportDesignerToolbar() {
}
};
const handleSaveAsTemplate = async (data: {
templateNameKor: string;
templateNameEng?: string;
description?: string;
}) => {
try {
// 현재 레이아웃 데이터로 직접 템플릿 생성 (리포트 저장 불필요)
const response = await reportApi.createTemplateFromLayout({
templateNameKor: data.templateNameKor,
templateNameEng: data.templateNameEng,
templateType: reportDetail?.report?.report_type || "GENERAL",
description: data.description,
layoutConfig: {
width: canvasWidth,
height: canvasHeight,
orientation: "portrait",
margins: {
top: 10,
bottom: 10,
left: 10,
right: 10,
},
components: components,
},
defaultQueries: queries.map((q, index) => ({
name: q.name,
type: q.type,
sqlQuery: q.sqlQuery,
parameters: q.parameters,
externalConnectionId: q.externalConnectionId || null,
displayOrder: index,
})),
});
if (response.success) {
toast({
title: "성공",
description: "템플릿이 생성되었습니다.",
});
setShowSaveAsTemplate(false);
}
} catch (error: unknown) {
const errorMessage =
error instanceof Error && "response" in error
? (error as { response?: { data?: { message?: string } } }).response?.data?.message ||
"템플릿 생성에 실패했습니다."
: "템플릿 생성에 실패했습니다.";
toast({
title: "오류",
description: errorMessage,
variant: "destructive",
});
throw error;
}
};
return (
<>
<div className="flex items-center justify-between border-b bg-white px-4 py-3 shadow-sm">
@@ -61,6 +128,17 @@ export function ReportDesignerToolbar() {
<Eye className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowSaveAsTemplate(true)}
disabled={!canSaveAsTemplate}
className="gap-2"
title={!canSaveAsTemplate ? "컴포넌트를 추가한 후 템플릿으로 저장할 수 있습니다" : ""}
>
<BookTemplate className="h-4 w-4" />
릿
</Button>
<Button variant="outline" size="sm" onClick={handleSave} disabled={isSaving} className="gap-2">
{isSaving ? (
<>
@@ -91,6 +169,11 @@ export function ReportDesignerToolbar() {
</div>
<ReportPreviewModal isOpen={showPreview} onClose={() => setShowPreview(false)} />
<SaveAsTemplateModal
isOpen={showSaveAsTemplate}
onClose={() => setShowSaveAsTemplate(false)}
onSave={handleSaveAsTemplate}
/>
</>
);
}

View File

@@ -0,0 +1,152 @@
"use client";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Loader2 } from "lucide-react";
interface SaveAsTemplateModalProps {
isOpen: boolean;
onClose: () => void;
onSave: (data: { templateNameKor: string; templateNameEng?: string; description?: string }) => Promise<void>;
}
export function SaveAsTemplateModal({ isOpen, onClose, onSave }: SaveAsTemplateModalProps) {
const [formData, setFormData] = useState({
templateNameKor: "",
templateNameEng: "",
description: "",
});
const [isSaving, setIsSaving] = useState(false);
const handleSave = async () => {
if (!formData.templateNameKor.trim()) {
alert("템플릿명을 입력해주세요.");
return;
}
setIsSaving(true);
try {
await onSave({
templateNameKor: formData.templateNameKor,
templateNameEng: formData.templateNameEng || undefined,
description: formData.description || undefined,
});
// 초기화
setFormData({
templateNameKor: "",
templateNameEng: "",
description: "",
});
onClose();
} catch (error) {
console.error("템플릿 저장 실패:", error);
} finally {
setIsSaving(false);
}
};
const handleClose = () => {
if (!isSaving) {
setFormData({
templateNameKor: "",
templateNameEng: "",
description: "",
});
onClose();
}
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>릿 </DialogTitle>
<DialogDescription>
릿 .
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="templateNameKor">
릿 () <span className="text-red-500">*</span>
</Label>
<Input
id="templateNameKor"
value={formData.templateNameKor}
onChange={(e) =>
setFormData({
...formData,
templateNameKor: e.target.value,
})
}
placeholder="예: 발주서 양식"
disabled={isSaving}
/>
</div>
<div className="space-y-2">
<Label htmlFor="templateNameEng">릿 ()</Label>
<Input
id="templateNameEng"
value={formData.templateNameEng}
onChange={(e) =>
setFormData({
...formData,
templateNameEng: e.target.value,
})
}
placeholder="예: Purchase Order Template"
disabled={isSaving}
/>
</div>
<div className="space-y-2">
<Label htmlFor="description"></Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) =>
setFormData({
...formData,
description: e.target.value,
})
}
placeholder="템플릿에 대한 간단한 설명을 입력하세요"
rows={3}
disabled={isSaving}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={handleClose} disabled={isSaving}>
</Button>
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
"저장"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,36 +1,149 @@
"use client";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { FileText } from "lucide-react";
import { Trash2, Loader2, RefreshCw } from "lucide-react";
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
import { reportApi } from "@/lib/api/reportApi";
import { useToast } from "@/hooks/use-toast";
import { Badge } from "@/components/ui/badge";
const TEMPLATES = [
const SYSTEM_TEMPLATES = [
{ id: "order", name: "발주서", icon: "📋" },
{ id: "invoice", name: "청구서", icon: "💰" },
{ id: "basic", name: "기본", icon: "📄" },
];
interface CustomTemplate {
template_id: string;
template_name_kor: string;
template_name_eng: string | null;
is_system: string;
}
export function TemplatePalette() {
const { applyTemplate } = useReportDesigner();
const [customTemplates, setCustomTemplates] = useState<CustomTemplate[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
const { toast } = useToast();
const handleApplyTemplate = (templateId: string) => {
applyTemplate(templateId);
const fetchCustomTemplates = async () => {
setIsLoading(true);
try {
const response = await reportApi.getTemplates();
if (response.success && response.data) {
setCustomTemplates(response.data.custom || []);
}
} catch (error) {
console.error("템플릿 조회 실패:", error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchCustomTemplates();
}, []);
const handleApplyTemplate = async (templateId: string) => {
await applyTemplate(templateId);
};
const handleDeleteTemplate = async (templateId: string, templateName: string) => {
if (!confirm(`"${templateName}" 템플릿을 삭제하시겠습니까?`)) {
return;
}
setDeletingId(templateId);
try {
const response = await reportApi.deleteTemplate(templateId);
if (response.success) {
toast({
title: "성공",
description: "템플릿이 삭제되었습니다.",
});
fetchCustomTemplates();
}
} catch (error: any) {
toast({
title: "오류",
description: error.response?.data?.message || "템플릿 삭제에 실패했습니다.",
variant: "destructive",
});
} finally {
setDeletingId(null);
}
};
return (
<div className="space-y-2">
{TEMPLATES.map((template) => (
<Button
key={template.id}
variant="outline"
size="sm"
className="w-full justify-start gap-2 text-sm"
onClick={() => handleApplyTemplate(template.id)}
>
<span>{template.icon}</span>
<span>{template.name}</span>
</Button>
))}
<div className="space-y-4">
{/* 시스템 템플릿 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold text-gray-600"> 릿</p>
</div>
{SYSTEM_TEMPLATES.map((template) => (
<Button
key={template.id}
variant="outline"
size="sm"
className="w-full justify-start gap-2 text-sm"
onClick={() => handleApplyTemplate(template.id)}
>
<span>{template.icon}</span>
<span>{template.name}</span>
</Button>
))}
</div>
{/* 사용자 정의 템플릿 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold text-gray-600"> 릿</p>
<Button variant="ghost" size="sm" onClick={fetchCustomTemplates} disabled={isLoading} className="h-6 w-6 p-0">
<RefreshCw className={`h-3 w-3 ${isLoading ? "animate-spin" : ""}`} />
</Button>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="h-4 w-4 animate-spin text-gray-400" />
</div>
) : customTemplates.length === 0 ? (
<p className="py-4 text-center text-xs text-gray-400"> 릿 </p>
) : (
customTemplates.map((template) => (
<div key={template.template_id} className="group relative">
<Button
variant="outline"
size="sm"
className="w-full justify-start gap-2 pr-8 text-sm"
onClick={() => handleApplyTemplate(template.template_id)}
>
<span>📄</span>
<span className="truncate">{template.template_name_kor}</span>
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleDeleteTemplate(template.template_id, template.template_name_kor);
}}
disabled={deletingId === template.template_id}
className="absolute top-1/2 right-1 h-6 w-6 -translate-y-1/2 p-0 opacity-0 transition-opacity group-hover:opacity-100"
>
{deletingId === template.template_id ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Trash2 className="h-3 w-3 text-red-500" />
)}
</Button>
</div>
))
)}
</div>
</div>
);
}

View File

@@ -529,35 +529,85 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
// 템플릿 적용
const applyTemplate = useCallback(
(templateId: string) => {
const templates = getTemplateLayout(templateId);
async (templateId: string) => {
try {
// 기존 컴포넌트가 있으면 확인
if (components.length > 0) {
if (!confirm("현재 레이아웃을 덮어씁니다. 계속하시겠습니까?")) {
return;
}
}
if (!templates) {
toast({
title: "오류",
description: "템플릿을 찾을 수 없습니다.",
variant: "destructive",
});
return;
}
// 1. 먼저 하드코딩된 시스템 템플릿 확인 (order, invoice, basic)
const systemTemplate = getTemplateLayout(templateId);
// 기존 컴포넌트가 있으면 확인
if (components.length > 0) {
if (!confirm("현재 레이아웃을 덮어씁니다. 계속하시겠습니까?")) {
if (systemTemplate) {
// 시스템 템플릿 적용
setComponents(systemTemplate.components);
setQueries(systemTemplate.queries);
toast({
title: "성공",
description: "템플릿이 적용되었습니다.",
});
return;
}
// 2. 사용자 정의 템플릿은 백엔드에서 조회
const response = await reportApi.getTemplates();
if (!response.success || !response.data) {
throw new Error("템플릿 목록을 불러올 수 없습니다.");
}
// 커스텀 템플릿 찾기
const customTemplates = response.data.custom || [];
const template = customTemplates.find((t: any) => t.template_id === templateId);
if (!template) {
throw new Error("템플릿을 찾을 수 없습니다.");
}
// 3. 템플릿 데이터 파싱 및 적용
const layoutConfig =
typeof template.layout_config === "string" ? JSON.parse(template.layout_config) : template.layout_config;
const defaultQueries =
typeof template.default_queries === "string"
? JSON.parse(template.default_queries)
: template.default_queries || [];
// 컴포넌트 적용 (ID 재생성)
const newComponents = layoutConfig.components.map((comp: any) => ({
...comp,
id: `comp-${Date.now()}-${Math.random()}`,
}));
// 쿼리 적용 (ID 재생성)
const newQueries = defaultQueries.map((q: any) => ({
id: `query-${Date.now()}-${Math.random()}`,
name: q.name,
type: q.type,
sqlQuery: q.sqlQuery,
parameters: q.parameters || [],
externalConnectionId: q.externalConnectionId || null,
}));
setComponents(newComponents);
setQueries(newQueries);
toast({
title: "성공",
description: "사용자 정의 템플릿이 적용되었습니다.",
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "템플릿 적용에 실패했습니다.";
toast({
title: "오류",
description: errorMessage,
variant: "destructive",
});
}
// 컴포넌트 배치
setComponents(templates.components);
// 쿼리 설정
setQueries(templates.queries);
toast({
title: "성공",
description: "템플릿이 적용되었습니다.",
});
},
[components.length, toast],
);

View File

@@ -147,4 +147,56 @@ export const reportApi = {
}>(`${BASE_URL}/external-connections`);
return response.data;
},
// 현재 리포트를 템플릿으로 저장
saveAsTemplate: async (
reportId: string,
data: {
templateNameKor: string;
templateNameEng?: string;
description?: string;
},
) => {
const response = await apiClient.post<{
success: boolean;
data: { templateId: string };
message: string;
}>(`${BASE_URL}/${reportId}/save-as-template`, data);
return response.data;
},
// 레이아웃 데이터로 직접 템플릿 생성 (리포트 저장 불필요)
createTemplateFromLayout: async (data: {
templateNameKor: string;
templateNameEng?: string;
templateType?: string;
description?: string;
layoutConfig: {
width: number;
height: number;
orientation: string;
margins: {
top: number;
bottom: number;
left: number;
right: number;
};
components: any[];
};
defaultQueries?: Array<{
name: string;
type: "MASTER" | "DETAIL";
sqlQuery: string;
parameters: string[];
externalConnectionId?: number | null;
displayOrder?: number;
}>;
}) => {
const response = await apiClient.post<{
success: boolean;
data: { templateId: string };
message: string;
}>(`${BASE_URL}/templates/create-from-layout`, data);
return response.data;
},
};