리포트 템플릿 저장 구현
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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% 완료)
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
152
frontend/components/report/designer/SaveAsTemplateModal.tsx
Normal file
152
frontend/components/report/designer/SaveAsTemplateModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user