/** * 리포트 관리 컨트롤러 */ import { Request, Response, NextFunction } from "express"; import reportService from "../services/reportService"; import { CreateReportRequest, UpdateReportRequest, SaveLayoutRequest, CreateTemplateRequest, } from "../types/report"; import path from "path"; import fs from "fs"; import { Document, Packer, Paragraph, TextRun, ImageRun, Table, TableRow, TableCell, WidthType, AlignmentType, VerticalAlign, BorderStyle, PageOrientation, convertMillimetersToTwip, } from "docx"; export class ReportController { /** * 리포트 목록 조회 * GET /api/admin/reports */ async getReports(req: Request, res: Response, next: NextFunction) { try { const { page = "1", limit = "20", searchText = "", reportType = "", useYn = "Y", sortBy = "created_at", sortOrder = "DESC", } = req.query; const result = await reportService.getReports({ page: parseInt(page as string, 10), limit: parseInt(limit as string, 10), searchText: searchText as string, reportType: reportType as string, useYn: useYn as string, sortBy: sortBy as string, sortOrder: sortOrder as "ASC" | "DESC", }); return res.json({ success: true, data: result, }); } catch (error) { return next(error); } } /** * 리포트 상세 조회 * GET /api/admin/reports/:reportId */ async getReportById(req: Request, res: Response, next: NextFunction) { try { const { reportId } = req.params; const report = await reportService.getReportById(reportId); if (!report) { return res.status(404).json({ success: false, message: "리포트를 찾을 수 없습니다.", }); } return res.json({ success: true, data: report, }); } catch (error) { return next(error); } } /** * 리포트 생성 * POST /api/admin/reports */ async createReport(req: Request, res: Response, next: NextFunction) { try { const data: CreateReportRequest = req.body; const userId = (req as any).user?.userId || "SYSTEM"; // 필수 필드 검증 if (!data.reportNameKor || !data.reportType) { return res.status(400).json({ success: false, message: "리포트명과 리포트 타입은 필수입니다.", }); } const reportId = await reportService.createReport(data, userId); return res.status(201).json({ success: true, data: { reportId, }, message: "리포트가 생성되었습니다.", }); } catch (error) { return next(error); } } /** * 리포트 수정 * PUT /api/admin/reports/:reportId */ async updateReport(req: Request, res: Response, next: NextFunction) { try { const { reportId } = req.params; const data: UpdateReportRequest = req.body; const userId = (req as any).user?.userId || "SYSTEM"; const success = await reportService.updateReport(reportId, data, userId); if (!success) { return res.status(400).json({ success: false, message: "수정할 내용이 없습니다.", }); } return res.json({ success: true, message: "리포트가 수정되었습니다.", }); } catch (error) { return next(error); } } /** * 리포트 삭제 * DELETE /api/admin/reports/:reportId */ async deleteReport(req: Request, res: Response, next: NextFunction) { try { const { reportId } = req.params; const success = await reportService.deleteReport(reportId); if (!success) { return res.status(404).json({ success: false, message: "리포트를 찾을 수 없습니다.", }); } return res.json({ success: true, message: "리포트가 삭제되었습니다.", }); } catch (error) { return next(error); } } /** * 리포트 복사 * POST /api/admin/reports/:reportId/copy */ async copyReport(req: Request, res: Response, next: NextFunction) { try { const { reportId } = req.params; const userId = (req as any).user?.userId || "SYSTEM"; const newReportId = await reportService.copyReport(reportId, userId); if (!newReportId) { return res.status(404).json({ success: false, message: "리포트를 찾을 수 없습니다.", }); } return res.status(201).json({ success: true, data: { reportId: newReportId, }, message: "리포트가 복사되었습니다.", }); } catch (error) { return next(error); } } /** * 레이아웃 조회 * GET /api/admin/reports/:reportId/layout */ async getLayout(req: Request, res: Response, next: NextFunction) { try { const { reportId } = req.params; const layout = await reportService.getLayout(reportId); if (!layout) { return res.status(404).json({ success: false, message: "레이아웃을 찾을 수 없습니다.", }); } // components 컬럼에서 JSON 파싱 const parsedComponents = layout.components ? JSON.parse(layout.components) : null; let layoutData; // 새 구조 (layoutConfig.pages)인지 확인 if (parsedComponents && parsedComponents.pages && Array.isArray(parsedComponents.pages)) { // pages 배열을 직접 포함하여 반환 layoutData = { ...layout, pages: parsedComponents.pages, components: [], // 호환성을 위해 빈 배열 }; } else { // 기존 구조: components 배열 layoutData = { ...layout, components: parsedComponents || [], }; } return res.json({ success: true, data: layoutData, }); } catch (error) { return next(error); } } /** * 레이아웃 저장 * PUT /api/admin/reports/:reportId/layout */ async saveLayout(req: Request, res: Response, next: NextFunction) { try { const { reportId } = req.params; const data: SaveLayoutRequest = req.body; const userId = (req as any).user?.userId || "SYSTEM"; // 필수 필드 검증 (페이지 기반 구조) if (!data.layoutConfig || !data.layoutConfig.pages || data.layoutConfig.pages.length === 0) { return res.status(400).json({ success: false, message: "레이아웃 설정이 필요합니다.", }); } await reportService.saveLayout(reportId, data, userId); return res.json({ success: true, message: "레이아웃이 저장되었습니다.", }); } catch (error) { return next(error); } } /** * 템플릿 목록 조회 * GET /api/admin/reports/templates */ async getTemplates(req: Request, res: Response, next: NextFunction) { try { const templates = await reportService.getTemplates(); return res.json({ success: true, data: templates, }); } catch (error) { return next(error); } } /** * 템플릿 생성 * POST /api/admin/reports/templates */ async createTemplate(req: Request, res: Response, next: NextFunction) { try { const data: CreateTemplateRequest = req.body; const userId = (req as any).user?.userId || "SYSTEM"; // 필수 필드 검증 if (!data.templateNameKor || !data.templateType) { return res.status(400).json({ success: false, message: "템플릿명과 템플릿 타입은 필수입니다.", }); } const templateId = await reportService.createTemplate(data, userId); return res.status(201).json({ success: true, data: { templateId, }, message: "템플릿이 생성되었습니다.", }); } catch (error) { return next(error); } } /** * 현재 리포트를 템플릿으로 저장 * 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 */ async deleteTemplate(req: Request, res: Response, next: NextFunction) { try { const { templateId } = req.params; const success = await reportService.deleteTemplate(templateId); if (!success) { return res.status(404).json({ success: false, message: "템플릿을 찾을 수 없거나 시스템 템플릿입니다.", }); } return res.json({ success: true, message: "템플릿이 삭제되었습니다.", }); } catch (error) { return next(error); } } /** * 쿼리 실행 * POST /api/admin/reports/:reportId/queries/:queryId/execute */ async executeQuery(req: Request, res: Response, next: NextFunction) { try { const { reportId, queryId } = req.params; const { parameters = {}, sqlQuery, externalConnectionId } = req.body; const result = await reportService.executeQuery( reportId, queryId, parameters, sqlQuery, externalConnectionId ); return res.json({ success: true, data: result, }); } catch (error: any) { return res.status(400).json({ success: false, message: error.message || "쿼리 실행에 실패했습니다.", }); } } /** * 외부 DB 연결 목록 조회 (활성화된 것만) * GET /api/admin/reports/external-connections */ async getExternalConnections( req: Request, res: Response, next: NextFunction ) { try { const { ExternalDbConnectionService } = await import( "../services/externalDbConnectionService" ); const result = await ExternalDbConnectionService.getConnections({ is_active: "Y", company_code: req.body.companyCode || "", }); return res.json(result); } catch (error) { return next(error); } } /** * 이미지 파일 업로드 * POST /api/admin/reports/upload-image */ async uploadImage(req: Request, res: Response, next: NextFunction) { try { if (!req.file) { return res.status(400).json({ success: false, message: "이미지 파일이 필요합니다.", }); } const companyCode = req.body.companyCode || "SYSTEM"; const file = req.file; // 파일 저장 경로 생성 const uploadDir = path.join( process.cwd(), "uploads", `company_${companyCode}`, "reports" ); // 디렉토리가 없으면 생성 if (!fs.existsSync(uploadDir)) { fs.mkdirSync(uploadDir, { recursive: true }); } // 고유한 파일명 생성 (타임스탬프 + 원본 파일명) const timestamp = Date.now(); const safeFileName = file.originalname.replace(/[^a-zA-Z0-9._-]/g, "_"); const fileName = `${timestamp}_${safeFileName}`; const filePath = path.join(uploadDir, fileName); // 파일 저장 fs.writeFileSync(filePath, file.buffer); // 웹에서 접근 가능한 URL 반환 const fileUrl = `/uploads/company_${companyCode}/reports/${fileName}`; return res.json({ success: true, data: { fileName, fileUrl, originalName: file.originalname, size: file.size, mimeType: file.mimetype, }, }); } catch (error) { return next(error); } } /** * 컴포넌트 데이터를 WORD(DOCX)로 변환 * POST /api/admin/reports/export-word */ async exportToWord(req: Request, res: Response, next: NextFunction) { try { const { layoutConfig, queryResults, fileName = "리포트" } = req.body; if (!layoutConfig || !layoutConfig.pages) { return res.status(400).json({ success: false, message: "레이아웃 데이터가 필요합니다.", }); } // mm를 twip으로 변환 const mmToTwip = (mm: number) => convertMillimetersToTwip(mm); // px를 twip으로 변환 (1px = 15twip at 96DPI) const pxToTwip = (px: number) => Math.round(px * 15); // 쿼리 결과 맵 const queryResultsMap: Record[] }> = queryResults || {}; // 컴포넌트 값 가져오기 const getComponentValue = (component: any): string => { if (component.queryId && component.fieldName) { const queryResult = queryResultsMap[component.queryId]; if (queryResult && queryResult.rows && queryResult.rows.length > 0) { const value = queryResult.rows[0][component.fieldName]; if (value !== null && value !== undefined) { return String(value); } } return `{${component.fieldName}}`; } return component.defaultValue || ""; }; // px → half-point 변환 (1px = 0.75pt, Word는 half-pt 단위 사용) // px * 0.75 * 2 = px * 1.5 const pxToHalfPt = (px: number) => Math.round(px * 1.5); // 셀 내용 생성 헬퍼 함수 (가로 배치용) const createCellContent = ( component: any, displayValue: string, pxToHalfPtFn: (px: number) => number, pxToTwipFn: (px: number) => number, queryResultsMapRef: Record[] }>, AlignmentTypeRef: typeof AlignmentType, VerticalAlignRef: typeof VerticalAlign, BorderStyleRef: typeof BorderStyle, ParagraphRef: typeof Paragraph, TextRunRef: typeof TextRun, ImageRunRef: typeof ImageRun, TableRef: typeof Table, TableRowRef: typeof TableRow, TableCellRef: typeof TableCell, pageIndex: number = 0, totalPages: number = 1 ): (Paragraph | Table)[] => { const result: (Paragraph | Table)[] = []; // Text/Label if (component.type === "text" || component.type === "label") { const fontSizeHalfPt = pxToHalfPtFn(component.fontSize || 13); const alignment = component.textAlign === "center" ? AlignmentTypeRef.CENTER : component.textAlign === "right" ? AlignmentTypeRef.RIGHT : AlignmentTypeRef.LEFT; // 줄바꿈 처리: \n으로 split하여 각 줄을 TextRun으로 생성 const lines = displayValue.split("\n"); const textChildren: TextRun[] = []; lines.forEach((line: string, index: number) => { if (index > 0) { // 줄바꿈 추가 (break: 1은 줄바꿈 1개) textChildren.push(new TextRunRef({ break: 1 })); } textChildren.push( new TextRunRef({ text: line, size: fontSizeHalfPt, color: (component.fontColor || "#000000").replace("#", ""), bold: component.fontWeight === "bold" || component.fontWeight === "600", font: "맑은 고딕", }) ); }); result.push( new ParagraphRef({ alignment, children: textChildren, }) ); } // Image else if (component.type === "image" && component.imageBase64) { try { const base64Data = component.imageBase64.split(",")[1] || component.imageBase64; const imageBuffer = Buffer.from(base64Data, "base64"); result.push( new ParagraphRef({ children: [ new ImageRunRef({ data: imageBuffer, transformation: { width: Math.round(component.width * 0.75), height: Math.round(component.height * 0.75), }, type: "png", }), ], }) ); } catch (e) { result.push(new ParagraphRef({ children: [] })); } } // Signature else if (component.type === "signature") { const sigFontSize = pxToHalfPtFn(component.fontSize || 12); const textRuns: TextRun[] = []; if (component.showLabel !== false) { textRuns.push(new TextRunRef({ text: (component.labelText || "서명:") + " ", size: sigFontSize, font: "맑은 고딕" })); } if (component.imageBase64) { try { const base64Data = component.imageBase64.split(",")[1] || component.imageBase64; const imageBuffer = Buffer.from(base64Data, "base64"); result.push( new ParagraphRef({ children: [ ...textRuns, new ImageRunRef({ data: imageBuffer, transformation: { width: Math.round(component.width * 0.75), height: Math.round(component.height * 0.75) }, type: "png", }), ], }) ); } catch (e) { textRuns.push(new TextRunRef({ text: "_".repeat(20), size: sigFontSize, font: "맑은 고딕" })); result.push(new ParagraphRef({ children: textRuns })); } } else { textRuns.push(new TextRunRef({ text: "_".repeat(20), size: sigFontSize, font: "맑은 고딕" })); result.push(new ParagraphRef({ children: textRuns })); } } // Stamp else if (component.type === "stamp") { const stampFontSize = pxToHalfPtFn(component.fontSize || 12); const textRuns: TextRun[] = []; if (component.personName) { textRuns.push(new TextRunRef({ text: component.personName + " ", size: stampFontSize, font: "맑은 고딕" })); } if (component.imageBase64) { try { const base64Data = component.imageBase64.split(",")[1] || component.imageBase64; const imageBuffer = Buffer.from(base64Data, "base64"); result.push( new ParagraphRef({ children: [ ...textRuns, new ImageRunRef({ data: imageBuffer, transformation: { width: Math.round(Math.min(component.width, component.height) * 0.75), height: Math.round(Math.min(component.width, component.height) * 0.75) }, type: "png", }), ], }) ); } catch (e) { textRuns.push(new TextRunRef({ text: "(인)", color: "DC2626", size: stampFontSize, font: "맑은 고딕" })); result.push(new ParagraphRef({ children: textRuns })); } } else { textRuns.push(new TextRunRef({ text: "(인)", color: "DC2626", size: stampFontSize, font: "맑은 고딕" })); result.push(new ParagraphRef({ children: textRuns })); } } // PageNumber else if (component.type === "pageNumber") { const format = component.pageNumberFormat || "number"; const currentPageNum = pageIndex + 1; let pageNumberText = ""; switch (format) { case "number": pageNumberText = `${currentPageNum}`; break; case "numberTotal": pageNumberText = `${currentPageNum} / ${totalPages}`; break; case "koreanNumber": pageNumberText = `${currentPageNum} 페이지`; break; default: pageNumberText = `${currentPageNum}`; } const fontSizeHalfPt = pxToHalfPtFn(component.fontSize || 13); const alignment = component.textAlign === "center" ? AlignmentTypeRef.CENTER : component.textAlign === "right" ? AlignmentTypeRef.RIGHT : AlignmentTypeRef.LEFT; result.push( new ParagraphRef({ alignment, children: [ new TextRunRef({ text: pageNumberText, size: fontSizeHalfPt, color: (component.fontColor || "#000000").replace("#", ""), font: "맑은 고딕", }), ], }) ); } // Divider - 테이블 셀로 감싸서 정확한 너비 적용 else if (component.type === "divider" && component.orientation === "horizontal") { result.push( new ParagraphRef({ border: { bottom: { color: (component.lineColor || "#000000").replace("#", ""), space: 1, style: BorderStyleRef.SINGLE, size: (component.lineWidth || 1) * 8, }, }, children: [], }) ); } // 기타 (빈 paragraph) else { result.push(new ParagraphRef({ children: [] })); } return result; }; // 섹션 생성 (페이지별) const sortedPages = layoutConfig.pages.sort((a: any, b: any) => a.page_order - b.page_order); const totalPagesCount = sortedPages.length; const sections = sortedPages .map((page: any, pageIndex: number) => { const pageWidthTwip = mmToTwip(page.width); const pageHeightTwip = mmToTwip(page.height); const marginTopMm = page.margins?.top || 10; const marginBottomMm = page.margins?.bottom || 10; const marginLeftMm = page.margins?.left || 10; const marginRightMm = page.margins?.right || 10; const marginTop = mmToTwip(marginTopMm); const marginBottom = mmToTwip(marginBottomMm); const marginLeft = mmToTwip(marginLeftMm); const marginRight = mmToTwip(marginRightMm); // 마진을 px로 변환 (1mm ≈ 3.78px at 96 DPI) const marginLeftPx = marginLeftMm * 3.78; const marginTopPx = marginTopMm * 3.78; // 컴포넌트를 Y좌표순으로 정렬 const sortedComponents = [...(page.components || [])].sort( (a: any, b: any) => a.y - b.y ); // 같은 Y좌표 범위(±30px)의 컴포넌트들을 그룹화 const Y_GROUP_THRESHOLD = 30; // px const componentGroups: any[][] = []; let currentGroup: any[] = []; let groupBaseY = -Infinity; for (const comp of sortedComponents) { const compY = comp.y - marginTopPx; if (currentGroup.length === 0) { currentGroup.push(comp); groupBaseY = compY; } else if (Math.abs(compY - groupBaseY) <= Y_GROUP_THRESHOLD) { currentGroup.push(comp); } else { componentGroups.push(currentGroup); currentGroup = [comp]; groupBaseY = compY; } } if (currentGroup.length > 0) { componentGroups.push(currentGroup); } // 컴포넌트를 Paragraph/Table로 변환 const children: (Paragraph | Table)[] = []; // Y좌표를 spacing으로 변환하기 위한 추적 변수 let lastBottomY = 0; // 각 그룹 처리 for (const group of componentGroups) { // 그룹 내 컴포넌트들을 X좌표 순으로 정렬 const sortedGroup = [...group].sort((a: any, b: any) => a.x - b.x); // 그룹의 Y 좌표 (첫 번째 컴포넌트 기준) const groupY = Math.max(0, sortedGroup[0].y - marginTopPx); const groupHeight = Math.max(...sortedGroup.map((c: any) => c.height)); // spacing 계산 const gapFromPrevious = Math.max(0, groupY - lastBottomY); const spacingBefore = pxToTwip(gapFromPrevious); // 그룹에 컴포넌트가 여러 개면 하나의 테이블 행으로 배치 if (sortedGroup.length > 1) { // spacing을 위한 빈 paragraph if (spacingBefore > 0) { children.push(new Paragraph({ spacing: { before: spacingBefore, after: 0 }, children: [] })); } // 각 컴포넌트를 셀로 변환 const cells: TableCell[] = []; let prevEndX = 0; for (const component of sortedGroup) { const adjustedX = Math.max(0, component.x - marginLeftPx); const displayValue = getComponentValue(component); // 이전 셀과의 간격을 위한 빈 셀 추가 if (adjustedX > prevEndX + 5) { const gapWidth = adjustedX - prevEndX; cells.push( new TableCell({ children: [new Paragraph({ children: [] })], width: { size: pxToTwip(gapWidth), type: WidthType.DXA }, borders: { top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, right: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, }, }) ); } // 컴포넌트 셀 생성 const cellContent = createCellContent(component, displayValue, pxToHalfPt, pxToTwip, queryResultsMap, AlignmentType, VerticalAlign, BorderStyle, Paragraph, TextRun, ImageRun, Table, TableRow, TableCell, pageIndex, totalPagesCount); cells.push( new TableCell({ children: cellContent, width: { size: pxToTwip(component.width), type: WidthType.DXA }, borders: { top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, right: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, }, verticalAlign: VerticalAlign.TOP, }) ); prevEndX = adjustedX + component.width; } // 테이블 행 생성 const rowTable = new Table({ rows: [new TableRow({ children: cells })], width: { size: 100, type: WidthType.PERCENTAGE }, borders: { top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, right: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, insideHorizontal: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, insideVertical: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, }, }); children.push(rowTable); lastBottomY = groupY + groupHeight; continue; } // 단일 컴포넌트 처리 (기존 로직) const component = sortedGroup[0]; const displayValue = getComponentValue(component); const adjustedX = Math.max(0, component.x - marginLeftPx); const adjustedY = groupY; // X좌표를 indent로 변환 (마진 제외한 순수 들여쓰기) const indentLeft = pxToTwip(adjustedX); // Text/Label 컴포넌트 - 테이블 셀로 감싸서 width 내 줄바꿈 적용 if (component.type === "text" || component.type === "label") { const fontSizeHalfPt = pxToHalfPt(component.fontSize || 13); const alignment = component.textAlign === "center" ? AlignmentType.CENTER : component.textAlign === "right" ? AlignmentType.RIGHT : AlignmentType.LEFT; // 줄바꿈 처리: \n으로 split하여 각 줄을 TextRun으로 생성 const lines = displayValue.split("\n"); const textChildren: TextRun[] = []; lines.forEach((line: string, index: number) => { if (index > 0) { textChildren.push(new TextRun({ break: 1 })); } textChildren.push( new TextRun({ text: line, size: fontSizeHalfPt, color: (component.fontColor || "#000000").replace("#", ""), bold: component.fontWeight === "bold" || component.fontWeight === "600", font: "맑은 고딕", }) ); }); // 테이블 셀로 감싸서 width 제한 → 자동 줄바꿈 const textCell = new TableCell({ children: [ new Paragraph({ alignment, children: textChildren, }), ], width: { size: pxToTwip(component.width), type: WidthType.DXA }, borders: { top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, right: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, }, verticalAlign: VerticalAlign.TOP, }); const textTable = new Table({ rows: [new TableRow({ children: [textCell] })], width: { size: pxToTwip(component.width), type: WidthType.DXA }, indent: { size: indentLeft, type: WidthType.DXA }, borders: { top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, right: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, insideHorizontal: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, insideVertical: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, }, }); // spacing을 위한 빈 paragraph if (spacingBefore > 0) { children.push(new Paragraph({ spacing: { before: spacingBefore, after: 0 }, children: [] })); } children.push(textTable); lastBottomY = adjustedY + component.height; } // Image 컴포넌트 else if (component.type === "image" && component.imageBase64) { try { const base64Data = component.imageBase64.split(",")[1] || component.imageBase64; const imageBuffer = Buffer.from(base64Data, "base64"); const paragraph = new Paragraph({ spacing: { before: spacingBefore, after: 0 }, indent: { left: indentLeft }, children: [ new ImageRun({ data: imageBuffer, transformation: { width: Math.round(component.width * 0.75), height: Math.round(component.height * 0.75), }, type: "png", }), ], }); children.push(paragraph); lastBottomY = adjustedY + component.height; } catch (imgError) { console.error("이미지 처리 오류:", imgError); } } // Divider 컴포넌트 - 테이블 셀로 감싸서 정확한 위치와 너비 적용 else if (component.type === "divider") { if (component.orientation === "horizontal") { // spacing을 위한 빈 paragraph if (spacingBefore > 0) { children.push(new Paragraph({ spacing: { before: spacingBefore, after: 0 }, children: [] })); } // 테이블 셀로 감싸서 너비 제한 const dividerCell = new TableCell({ children: [ new Paragraph({ border: { bottom: { color: (component.lineColor || "#000000").replace("#", ""), space: 1, style: BorderStyle.SINGLE, size: (component.lineWidth || 1) * 8, }, }, children: [], }), ], width: { size: pxToTwip(component.width), type: WidthType.DXA }, borders: { top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, right: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, }, }); const dividerTable = new Table({ rows: [new TableRow({ children: [dividerCell] })], width: { size: pxToTwip(component.width), type: WidthType.DXA }, indent: { size: indentLeft, type: WidthType.DXA }, borders: { top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, right: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, insideHorizontal: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, insideVertical: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, }, }); children.push(dividerTable); lastBottomY = adjustedY + component.height; } } // Signature 컴포넌트 else if (component.type === "signature") { const labelText = component.labelText || "서명:"; const showLabel = component.showLabel !== false; const sigFontSize = pxToHalfPt(component.fontSize || 12); const textRuns: TextRun[] = []; if (showLabel) { textRuns.push( new TextRun({ text: labelText + " ", size: sigFontSize, font: "맑은 고딕", }) ); } if (component.imageBase64) { try { const base64Data = component.imageBase64.split(",")[1] || component.imageBase64; const imageBuffer = Buffer.from(base64Data, "base64"); const paragraph = new Paragraph({ spacing: { before: spacingBefore, after: 0 }, indent: { left: indentLeft }, children: [ ...textRuns, new ImageRun({ data: imageBuffer, transformation: { width: Math.round(component.width * 0.75), height: Math.round(component.height * 0.75), }, type: "png", }), ], }); children.push(paragraph); } catch (imgError) { console.error("서명 이미지 오류:", imgError); textRuns.push(new TextRun({ text: "_".repeat(20), size: sigFontSize, font: "맑은 고딕" })); children.push(new Paragraph({ spacing: { before: spacingBefore, after: 0 }, indent: { left: indentLeft }, children: textRuns })); } } else { textRuns.push(new TextRun({ text: "_".repeat(20), size: sigFontSize, font: "맑은 고딕" })); children.push(new Paragraph({ spacing: { before: spacingBefore, after: 0 }, indent: { left: indentLeft }, children: textRuns })); } lastBottomY = adjustedY + component.height; } // Stamp 컴포넌트 else if (component.type === "stamp") { const personName = component.personName || ""; const stampFontSize = pxToHalfPt(component.fontSize || 12); const textRuns: TextRun[] = []; if (personName) { textRuns.push( new TextRun({ text: personName + " ", size: stampFontSize, font: "맑은 고딕", }) ); } if (component.imageBase64) { try { const base64Data = component.imageBase64.split(",")[1] || component.imageBase64; const imageBuffer = Buffer.from(base64Data, "base64"); const paragraph = new Paragraph({ spacing: { before: spacingBefore, after: 0 }, indent: { left: indentLeft }, children: [ ...textRuns, new ImageRun({ data: imageBuffer, transformation: { width: Math.round(Math.min(component.width, component.height) * 0.75), height: Math.round(Math.min(component.width, component.height) * 0.75), }, type: "png", }), ], }); children.push(paragraph); } catch (imgError) { console.error("도장 이미지 오류:", imgError); textRuns.push(new TextRun({ text: "(인)", color: "DC2626", size: stampFontSize, font: "맑은 고딕" })); children.push(new Paragraph({ spacing: { before: spacingBefore, after: 0 }, indent: { left: indentLeft }, children: textRuns })); } } else { textRuns.push(new TextRun({ text: "(인)", color: "DC2626", size: stampFontSize, font: "맑은 고딕" })); children.push(new Paragraph({ spacing: { before: spacingBefore, after: 0 }, indent: { left: indentLeft }, children: textRuns })); } lastBottomY = adjustedY + component.height; } // PageNumber 컴포넌트 - 테이블 셀로 감싸서 정확한 위치 적용 else if (component.type === "pageNumber") { const format = component.pageNumberFormat || "number"; const currentPageNum = pageIndex + 1; let pageNumberText = ""; switch (format) { case "number": pageNumberText = `${currentPageNum}`; break; case "numberTotal": pageNumberText = `${currentPageNum} / ${totalPagesCount}`; break; case "koreanNumber": pageNumberText = `${currentPageNum} 페이지`; break; default: pageNumberText = `${currentPageNum}`; } const pageNumFontSize = pxToHalfPt(component.fontSize || 13); const alignment = component.textAlign === "center" ? AlignmentType.CENTER : component.textAlign === "right" ? AlignmentType.RIGHT : AlignmentType.LEFT; // 테이블 셀로 감싸서 width와 indent 정확히 적용 const pageNumCell = new TableCell({ children: [ new Paragraph({ alignment, children: [ new TextRun({ text: pageNumberText, size: pageNumFontSize, color: (component.fontColor || "#000000").replace("#", ""), font: "맑은 고딕", }), ], }), ], width: { size: pxToTwip(component.width), type: WidthType.DXA }, borders: { top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, right: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, }, verticalAlign: VerticalAlign.TOP, }); const pageNumTable = new Table({ rows: [new TableRow({ children: [pageNumCell] })], width: { size: pxToTwip(component.width), type: WidthType.DXA }, indent: { size: indentLeft, type: WidthType.DXA }, borders: { top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, right: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, insideHorizontal: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, insideVertical: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, }, }); // spacing을 위한 빈 paragraph if (spacingBefore > 0) { children.push(new Paragraph({ spacing: { before: spacingBefore, after: 0 }, children: [] })); } children.push(pageNumTable); lastBottomY = adjustedY + component.height; } // Table 컴포넌트 else if (component.type === "table" && component.queryId) { const queryResult = queryResultsMap[component.queryId]; if (queryResult && queryResult.rows && queryResult.rows.length > 0) { // 테이블 앞에 spacing과 indent를 위한 빈 paragraph 추가 if (spacingBefore > 0 || indentLeft > 0) { children.push(new Paragraph({ spacing: { before: spacingBefore, after: 0 }, indent: { left: indentLeft }, children: [] })); } const columns = component.tableColumns && component.tableColumns.length > 0 ? component.tableColumns : queryResult.fields.map((field: string) => ({ field, header: field, align: "left", width: undefined, })); // 테이블 폰트 사이즈 (기본 12px) const tableFontSize = pxToHalfPt(component.fontSize || 12); // 헤더 행 const headerCells = columns.map( (col: { header: string; align?: string }) => new TableCell({ children: [ new Paragraph({ alignment: col.align === "center" ? AlignmentType.CENTER : col.align === "right" ? AlignmentType.RIGHT : AlignmentType.LEFT, children: [ new TextRun({ text: col.header, bold: true, size: tableFontSize, font: "맑은 고딕", }), ], }), ], shading: { fill: (component.headerBackgroundColor || "#f3f4f6").replace("#", ""), }, verticalAlign: VerticalAlign.CENTER, }) ); const headerRow = new TableRow({ children: headerCells }); // 데이터 행 const dataRows = queryResult.rows.map( (row: Record) => new TableRow({ children: columns.map( (col: { field: string; align?: string }) => new TableCell({ children: [ new Paragraph({ alignment: col.align === "center" ? AlignmentType.CENTER : col.align === "right" ? AlignmentType.RIGHT : AlignmentType.LEFT, children: [ new TextRun({ text: String(row[col.field] ?? ""), size: tableFontSize, font: "맑은 고딕", }), ], }), ], verticalAlign: VerticalAlign.CENTER, }) ), }) ); const table = new Table({ width: { size: pxToTwip(component.width), type: WidthType.DXA }, indent: { size: indentLeft, type: WidthType.DXA }, rows: [headerRow, ...dataRows], }); children.push(table); lastBottomY = adjustedY + component.height; } } } // 빈 페이지 방지 if (children.length === 0) { children.push(new Paragraph({ children: [] })); } return { properties: { page: { size: { width: pageWidthTwip, height: pageHeightTwip, orientation: page.width > page.height ? PageOrientation.LANDSCAPE : PageOrientation.PORTRAIT, }, margin: { top: marginTop, bottom: marginBottom, left: marginLeft, right: marginRight, }, }, }, children, }; }); // Document 생성 const doc = new Document({ sections, }); // Buffer로 변환 const docxBuffer = await Packer.toBuffer(doc); // 파일명 인코딩 (한글 지원) const timestamp = new Date().toISOString().slice(0, 10); const safeFileName = encodeURIComponent(`${fileName}_${timestamp}.docx`); // DOCX 파일로 응답 res.setHeader( "Content-Type", "application/vnd.openxmlformats-officedocument.wordprocessingml.document" ); res.setHeader( "Content-Disposition", `attachment; filename*=UTF-8''${safeFileName}` ); res.setHeader("Content-Length", docxBuffer.length); return res.send(docxBuffer); } catch (error: any) { console.error("WORD 변환 오류:", error); return res.status(500).json({ success: false, message: error.message || "WORD 변환에 실패했습니다.", }); } } } export default new ReportController();