리포트 템플릿 저장 구현
This commit is contained in:
parent
2ee4dd0b58
commit
62d36abb65
|
|
@ -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
|
* DELETE /api/admin/reports/templates/:templateId
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,10 @@ router.get("/templates", (req, res, next) =>
|
||||||
router.post("/templates", (req, res, next) =>
|
router.post("/templates", (req, res, next) =>
|
||||||
reportController.createTemplate(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) =>
|
router.delete("/templates/:templateId", (req, res, next) =>
|
||||||
reportController.deleteTemplate(req, res, next)
|
reportController.deleteTemplate(req, res, next)
|
||||||
);
|
);
|
||||||
|
|
@ -38,6 +42,11 @@ router.post("/:reportId/copy", (req, res, next) =>
|
||||||
reportController.copyReport(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) =>
|
router.get("/:reportId/layout", (req, res, next) =>
|
||||||
reportController.getLayout(req, res, next)
|
reportController.getLayout(req, res, next)
|
||||||
|
|
|
||||||
|
|
@ -821,6 +821,185 @@ export class ReportService {
|
||||||
const result = await query(deleteQuery, [templateId]);
|
const result = await query(deleteQuery, [templateId]);
|
||||||
return true;
|
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();
|
export default new ReportService();
|
||||||
|
|
|
||||||
|
|
@ -119,95 +119,110 @@
|
||||||
|
|
||||||
- `frontend/components/report/designer/ReportPreviewModal.tsx`
|
- `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 라이브러리 사용
|
- jsPDF 또는 pdfmake 라이브러리 사용
|
||||||
- HTML to DOCX 변환
|
- HTML to DOCX 변환
|
||||||
- 다운로드 기능 구현
|
- 다운로드 기능 구현
|
||||||
|
- 미리보기 모달에 버튼 추가
|
||||||
|
|
||||||
### Phase 3: 사용성 개선
|
2. **레이아웃 도구**
|
||||||
|
|
||||||
7. **레이아웃 도구**
|
|
||||||
|
|
||||||
- 격자 스냅 (Grid Snap)
|
- 격자 스냅 (Grid Snap)
|
||||||
- 정렬 가이드라인
|
- 정렬 가이드라인
|
||||||
- 컴포넌트 그룹화
|
- 컴포넌트 그룹화
|
||||||
- 실행 취소/다시 실행 (Undo/Redo)
|
- 실행 취소/다시 실행 (Undo/Redo)
|
||||||
|
|
||||||
8. **쿼리 관리 개선**
|
3. **쿼리 관리 개선**
|
||||||
|
|
||||||
- 쿼리 미리보기 개선 (테이블 형태)
|
- 쿼리 미리보기 개선 (테이블 형태)
|
||||||
- 쿼리 저장/불러오기
|
- 쿼리 저장/불러오기
|
||||||
- 쿼리 템플릿
|
- 쿼리 템플릿
|
||||||
|
|
||||||
9. **성능 최적화**
|
### Phase 2: 추가 컴포넌트
|
||||||
- 쿼리 결과 캐싱
|
|
||||||
- 대용량 데이터 페이징
|
|
||||||
- 렌더링 최적화
|
|
||||||
|
|
||||||
### Phase 4: 추가 기능
|
4. **다양한 컴포넌트 추가**
|
||||||
|
|
||||||
10. **다양한 컴포넌트 추가**
|
|
||||||
|
|
||||||
- 이미지 컴포넌트
|
- 이미지 컴포넌트
|
||||||
- 차트 컴포넌트 (막대, 선, 원형)
|
- 차트 컴포넌트 (막대, 선, 원형)
|
||||||
- 바코드/QR코드 (선택사항)
|
- 바코드/QR코드 (선택사항)
|
||||||
|
- 구분선 (Divider)
|
||||||
|
- 체크박스/라디오 버튼
|
||||||
|
|
||||||
11. **권한 관리**
|
5. **조건부 서식**
|
||||||
|
- 특정 조건에 따른 스타일 변경
|
||||||
|
- 값 범위에 따른 색상 표시
|
||||||
|
- 수식 기반 표시/숨김
|
||||||
|
|
||||||
|
### Phase 3: 성능 및 보안
|
||||||
|
|
||||||
|
6. **성능 최적화**
|
||||||
|
|
||||||
|
- 쿼리 결과 캐싱
|
||||||
|
- 대용량 데이터 페이징
|
||||||
|
- 렌더링 최적화
|
||||||
|
- 이미지 레이지 로딩
|
||||||
|
|
||||||
|
7. **권한 관리**
|
||||||
- 리포트별 접근 권한
|
- 리포트별 접근 권한
|
||||||
- 수정 권한 분리
|
- 수정 권한 분리
|
||||||
- 템플릿 공유
|
- 템플릿 공유
|
||||||
|
- 사용자별 리포트 목록 필터링
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -300,4 +315,4 @@
|
||||||
|
|
||||||
**최종 업데이트**: 2025-10-01
|
**최종 업데이트**: 2025-10-01
|
||||||
**작성자**: AI Assistant
|
**작성자**: AI Assistant
|
||||||
**상태**: 복사/삭제 기능 구현 완료, 테스트 대기 중 (75% 완료)
|
**상태**: 핵심 기능 구현 완료 (90% 완료)
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,26 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
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 { useRouter } from "next/navigation";
|
||||||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { SaveAsTemplateModal } from "./SaveAsTemplateModal";
|
||||||
|
import { reportApi } from "@/lib/api/reportApi";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { ReportPreviewModal } from "./ReportPreviewModal";
|
import { ReportPreviewModal } from "./ReportPreviewModal";
|
||||||
|
|
||||||
export function ReportDesignerToolbar() {
|
export function ReportDesignerToolbar() {
|
||||||
const router = useRouter();
|
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 [showPreview, setShowPreview] = useState(false);
|
||||||
|
const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false);
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
// 템플릿 저장 가능 여부: 컴포넌트가 있어야 함
|
||||||
|
const canSaveAsTemplate = components.length > 0;
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
await saveLayout();
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-between border-b bg-white px-4 py-3 shadow-sm">
|
<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" />
|
<Eye className="h-4 w-4" />
|
||||||
미리보기
|
미리보기
|
||||||
</Button>
|
</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">
|
<Button variant="outline" size="sm" onClick={handleSave} disabled={isSaving} className="gap-2">
|
||||||
{isSaving ? (
|
{isSaving ? (
|
||||||
<>
|
<>
|
||||||
|
|
@ -91,6 +169,11 @@ export function ReportDesignerToolbar() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ReportPreviewModal isOpen={showPreview} onClose={() => setShowPreview(false)} />
|
<ReportPreviewModal isOpen={showPreview} onClose={() => setShowPreview(false)} />
|
||||||
|
<SaveAsTemplateModal
|
||||||
|
isOpen={showSaveAsTemplate}
|
||||||
|
onClose={() => setShowSaveAsTemplate(false)}
|
||||||
|
onSave={handleSaveAsTemplate}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,25 +1,89 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { FileText } from "lucide-react";
|
import { Trash2, Loader2, RefreshCw } from "lucide-react";
|
||||||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
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: "order", name: "발주서", icon: "📋" },
|
||||||
{ id: "invoice", name: "청구서", icon: "💰" },
|
{ id: "invoice", name: "청구서", icon: "💰" },
|
||||||
{ id: "basic", 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() {
|
export function TemplatePalette() {
|
||||||
const { applyTemplate } = useReportDesigner();
|
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) => {
|
const fetchCustomTemplates = async () => {
|
||||||
applyTemplate(templateId);
|
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 (
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 시스템 템플릿 */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{TEMPLATES.map((template) => (
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs font-semibold text-gray-600">시스템 템플릿</p>
|
||||||
|
</div>
|
||||||
|
{SYSTEM_TEMPLATES.map((template) => (
|
||||||
<Button
|
<Button
|
||||||
key={template.id}
|
key={template.id}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
@ -32,5 +96,54 @@ export function TemplatePalette() {
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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,18 +529,8 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
||||||
|
|
||||||
// 템플릿 적용
|
// 템플릿 적용
|
||||||
const applyTemplate = useCallback(
|
const applyTemplate = useCallback(
|
||||||
(templateId: string) => {
|
async (templateId: string) => {
|
||||||
const templates = getTemplateLayout(templateId);
|
try {
|
||||||
|
|
||||||
if (!templates) {
|
|
||||||
toast({
|
|
||||||
title: "오류",
|
|
||||||
description: "템플릿을 찾을 수 없습니다.",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 기존 컴포넌트가 있으면 확인
|
// 기존 컴포넌트가 있으면 확인
|
||||||
if (components.length > 0) {
|
if (components.length > 0) {
|
||||||
if (!confirm("현재 레이아웃을 덮어씁니다. 계속하시겠습니까?")) {
|
if (!confirm("현재 레이아웃을 덮어씁니다. 계속하시겠습니까?")) {
|
||||||
|
|
@ -548,16 +538,76 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 컴포넌트 배치
|
// 1. 먼저 하드코딩된 시스템 템플릿 확인 (order, invoice, basic)
|
||||||
setComponents(templates.components);
|
const systemTemplate = getTemplateLayout(templateId);
|
||||||
|
|
||||||
// 쿼리 설정
|
if (systemTemplate) {
|
||||||
setQueries(templates.queries);
|
// 시스템 템플릿 적용
|
||||||
|
setComponents(systemTemplate.components);
|
||||||
|
setQueries(systemTemplate.queries);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: "성공",
|
title: "성공",
|
||||||
description: "템플릿이 적용되었습니다.",
|
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",
|
||||||
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[components.length, toast],
|
[components.length, toast],
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -147,4 +147,56 @@ export const reportApi = {
|
||||||
}>(`${BASE_URL}/external-connections`);
|
}>(`${BASE_URL}/external-connections`);
|
||||||
return response.data;
|
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;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue