From 62d36abb65ce53d824b4b3b986c9e775e7aac0b5 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Wed, 1 Oct 2025 15:03:52 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A6=AC=ED=8F=AC=ED=8A=B8=20=ED=85=9C?= =?UTF-8?q?=ED=94=8C=EB=A6=BF=20=EC=A0=80=EC=9E=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/reportController.ts | 95 ++++++++++ backend-node/src/routes/reportRoutes.ts | 9 + backend-node/src/services/reportService.ts | 179 ++++++++++++++++++ docs/리포트_관리_시스템_구현_진행상황.md | 137 ++++++++------ .../report/designer/ReportDesignerToolbar.tsx | 87 ++++++++- .../report/designer/SaveAsTemplateModal.tsx | 152 +++++++++++++++ .../report/designer/TemplatePalette.tsx | 147 ++++++++++++-- frontend/contexts/ReportDesignerContext.tsx | 98 +++++++--- frontend/lib/api/reportApi.ts | 52 +++++ 9 files changed, 852 insertions(+), 104 deletions(-) create mode 100644 frontend/components/report/designer/SaveAsTemplateModal.tsx diff --git a/backend-node/src/controllers/reportController.ts b/backend-node/src/controllers/reportController.ts index 256aba68..af9ec4c3 100644 --- a/backend-node/src/controllers/reportController.ts +++ b/backend-node/src/controllers/reportController.ts @@ -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 diff --git a/backend-node/src/routes/reportRoutes.ts b/backend-node/src/routes/reportRoutes.ts index b511e28b..aaa0449e 100644 --- a/backend-node/src/routes/reportRoutes.ts +++ b/backend-node/src/routes/reportRoutes.ts @@ -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) diff --git a/backend-node/src/services/reportService.ts b/backend-node/src/services/reportService.ts index ecf49cb2..3416c2ea 100644 --- a/backend-node/src/services/reportService.ts +++ b/backend-node/src/services/reportService.ts @@ -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 { + 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 { + 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(); diff --git a/docs/리포트_관리_시스템_구현_진행상황.md b/docs/리포트_관리_시스템_구현_진행상황.md index 9a60765b..66bad755 100644 --- a/docs/리포트_관리_시스템_구현_진행상황.md +++ b/docs/리포트_관리_시스템_구현_진행상황.md @@ -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% 완료) diff --git a/frontend/components/report/designer/ReportDesignerToolbar.tsx b/frontend/components/report/designer/ReportDesignerToolbar.tsx index f2ca1b74..bb49e6bd 100644 --- a/frontend/components/report/designer/ReportDesignerToolbar.tsx +++ b/frontend/components/report/designer/ReportDesignerToolbar.tsx @@ -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 ( <>
@@ -61,6 +128,17 @@ export function ReportDesignerToolbar() { 미리보기 +
setShowPreview(false)} /> + setShowSaveAsTemplate(false)} + onSave={handleSaveAsTemplate} + /> ); } diff --git a/frontend/components/report/designer/SaveAsTemplateModal.tsx b/frontend/components/report/designer/SaveAsTemplateModal.tsx new file mode 100644 index 00000000..7b471bb8 --- /dev/null +++ b/frontend/components/report/designer/SaveAsTemplateModal.tsx @@ -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; +} + +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 ( + + + + 템플릿으로 저장 + + 현재 리포트 레이아웃을 템플릿으로 저장하면 다른 리포트에서 재사용할 수 있습니다. + + + +
+
+ + + setFormData({ + ...formData, + templateNameKor: e.target.value, + }) + } + placeholder="예: 발주서 양식" + disabled={isSaving} + /> +
+ +
+ + + setFormData({ + ...formData, + templateNameEng: e.target.value, + }) + } + placeholder="예: Purchase Order Template" + disabled={isSaving} + /> +
+ +
+ +