3177 lines
107 KiB
TypeScript
3177 lines
107 KiB
TypeScript
/**
|
|
* 리포트 관리 컨트롤러
|
|
*/
|
|
|
|
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,
|
|
Header,
|
|
Footer,
|
|
HeadingLevel,
|
|
TableLayoutType,
|
|
} from "docx";
|
|
import { WatermarkConfig } from "../types/report";
|
|
import bwipjs from "bwip-js";
|
|
|
|
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);
|
|
|
|
// 프론트엔드와 동일한 MM_TO_PX 상수 (캔버스에서 mm를 px로 변환할 때 사용하는 값)
|
|
const MM_TO_PX = 4;
|
|
// 1mm = 56.692913386 twip (docx 라이브러리 기준)
|
|
// px를 twip으로 변환: px -> mm -> twip
|
|
const pxToTwip = (px: number) => Math.round((px / MM_TO_PX) * 56.692913386);
|
|
|
|
// 쿼리 결과 맵
|
|
const queryResultsMap: Record<
|
|
string,
|
|
{ fields: string[]; rows: Record<string, unknown>[] }
|
|
> = 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<
|
|
string,
|
|
{ fields: string[]; rows: Record<string, unknown>[] }
|
|
>,
|
|
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");
|
|
// 서명 이미지 크기: 라벨 옆에 인라인으로 표시될 수 있도록 적절한 크기로 조정
|
|
const sigImageHeight = 30; // 고정 높이 (약 40px)
|
|
const sigImageWidth = Math.round((component.width / component.height) * sigImageHeight) || 80;
|
|
result.push(
|
|
new ParagraphRef({
|
|
children: [
|
|
...textRuns,
|
|
new ImageRunRef({
|
|
data: imageBuffer,
|
|
transformation: {
|
|
width: sigImageWidth,
|
|
height: sigImageHeight,
|
|
},
|
|
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: "맑은 고딕",
|
|
}),
|
|
],
|
|
})
|
|
);
|
|
}
|
|
|
|
// Card 컴포넌트
|
|
else if (component.type === "card") {
|
|
const cardTitle = component.cardTitle || "정보 카드";
|
|
const cardItems = component.cardItems || [];
|
|
const labelWidth = component.labelWidth || 80;
|
|
const showCardTitle = component.showCardTitle !== false;
|
|
const titleFontSize = pxToHalfPtFn(component.titleFontSize || 14);
|
|
const labelFontSize = pxToHalfPtFn(component.labelFontSize || 13);
|
|
const valueFontSize = pxToHalfPtFn(component.valueFontSize || 13);
|
|
const titleColor = (component.titleColor || "#1e40af").replace(
|
|
"#",
|
|
""
|
|
);
|
|
const labelColor = (component.labelColor || "#374151").replace(
|
|
"#",
|
|
""
|
|
);
|
|
const valueColor = (component.valueColor || "#000000").replace(
|
|
"#",
|
|
""
|
|
);
|
|
const borderColor = (component.borderColor || "#e5e7eb").replace(
|
|
"#",
|
|
""
|
|
);
|
|
|
|
// 쿼리 바인딩된 값 가져오기
|
|
const getCardValueFn = (item: {
|
|
label: string;
|
|
value: string;
|
|
fieldName?: string;
|
|
}) => {
|
|
if (
|
|
item.fieldName &&
|
|
component.queryId &&
|
|
queryResultsMapRef[component.queryId]
|
|
) {
|
|
const qResult = queryResultsMapRef[component.queryId];
|
|
if (qResult.rows && qResult.rows.length > 0) {
|
|
const row = qResult.rows[0];
|
|
return row[item.fieldName] !== undefined
|
|
? String(row[item.fieldName])
|
|
: item.value;
|
|
}
|
|
}
|
|
return item.value;
|
|
};
|
|
|
|
// 제목
|
|
if (showCardTitle) {
|
|
result.push(
|
|
new ParagraphRef({
|
|
children: [
|
|
new TextRunRef({
|
|
text: cardTitle,
|
|
size: titleFontSize,
|
|
color: titleColor,
|
|
bold: true,
|
|
font: "맑은 고딕",
|
|
}),
|
|
],
|
|
})
|
|
);
|
|
// 구분선
|
|
result.push(
|
|
new ParagraphRef({
|
|
border: {
|
|
bottom: {
|
|
color: borderColor,
|
|
space: 1,
|
|
style: BorderStyleRef.SINGLE,
|
|
size: 8,
|
|
},
|
|
},
|
|
children: [],
|
|
})
|
|
);
|
|
}
|
|
|
|
// 항목들
|
|
for (const item of cardItems) {
|
|
const itemValue = getCardValueFn(
|
|
item as { label: string; value: string; fieldName?: string }
|
|
);
|
|
result.push(
|
|
new ParagraphRef({
|
|
children: [
|
|
new TextRunRef({
|
|
text: item.label,
|
|
size: labelFontSize,
|
|
color: labelColor,
|
|
bold: true,
|
|
font: "맑은 고딕",
|
|
}),
|
|
new TextRunRef({
|
|
text: " ",
|
|
size: labelFontSize,
|
|
font: "맑은 고딕",
|
|
}),
|
|
new TextRunRef({
|
|
text: itemValue,
|
|
size: valueFontSize,
|
|
color: valueColor,
|
|
font: "맑은 고딕",
|
|
}),
|
|
],
|
|
})
|
|
);
|
|
}
|
|
}
|
|
|
|
// 계산 컴포넌트
|
|
else if (component.type === "calculation") {
|
|
const calcItems = component.calcItems || [];
|
|
const resultLabel = component.resultLabel || "합계";
|
|
const calcLabelWidth = component.labelWidth || 120;
|
|
const calcLabelFontSize = pxToHalfPtFn(component.labelFontSize || 13);
|
|
const calcValueFontSize = pxToHalfPtFn(component.valueFontSize || 13);
|
|
const calcResultFontSize = pxToHalfPtFn(
|
|
component.resultFontSize || 16
|
|
);
|
|
const calcLabelColor = (component.labelColor || "#374151").replace(
|
|
"#",
|
|
""
|
|
);
|
|
const calcValueColor = (component.valueColor || "#000000").replace(
|
|
"#",
|
|
""
|
|
);
|
|
const calcResultColor = (component.resultColor || "#2563eb").replace(
|
|
"#",
|
|
""
|
|
);
|
|
const numberFormat = component.numberFormat || "currency";
|
|
const currencySuffix = component.currencySuffix || "원";
|
|
const borderColor = (component.borderColor || "#374151").replace(
|
|
"#",
|
|
""
|
|
);
|
|
|
|
// 숫자 포맷팅 함수
|
|
const formatNumberFn = (num: number): string => {
|
|
if (numberFormat === "none") return String(num);
|
|
if (numberFormat === "comma") return num.toLocaleString();
|
|
if (numberFormat === "currency")
|
|
return num.toLocaleString() + currencySuffix;
|
|
return String(num);
|
|
};
|
|
|
|
// 쿼리 바인딩된 값 가져오기
|
|
const getCalcItemValueFn = (item: {
|
|
label: string;
|
|
value: number | string;
|
|
operator: string;
|
|
fieldName?: string;
|
|
}): number => {
|
|
if (
|
|
item.fieldName &&
|
|
component.queryId &&
|
|
queryResultsMapRef[component.queryId]
|
|
) {
|
|
const qResult = queryResultsMapRef[component.queryId];
|
|
if (qResult.rows && qResult.rows.length > 0) {
|
|
const row = qResult.rows[0];
|
|
const val = row[item.fieldName];
|
|
return typeof val === "number"
|
|
? val
|
|
: parseFloat(String(val)) || 0;
|
|
}
|
|
}
|
|
return typeof item.value === "number"
|
|
? item.value
|
|
: parseFloat(String(item.value)) || 0;
|
|
};
|
|
|
|
// 계산 결과 (첫 번째 항목은 기준값, 두 번째부터 연산자 적용)
|
|
let calcResult = 0;
|
|
if (calcItems.length > 0) {
|
|
// 첫 번째 항목은 기준값
|
|
calcResult = getCalcItemValueFn(
|
|
calcItems[0] as {
|
|
label: string;
|
|
value: number | string;
|
|
operator: string;
|
|
fieldName?: string;
|
|
}
|
|
);
|
|
|
|
// 두 번째 항목부터 연산자 적용
|
|
for (let i = 1; i < calcItems.length; i++) {
|
|
const item = calcItems[i];
|
|
const val = getCalcItemValueFn(
|
|
item as {
|
|
label: string;
|
|
value: number | string;
|
|
operator: string;
|
|
fieldName?: string;
|
|
}
|
|
);
|
|
switch ((item as { operator: string }).operator) {
|
|
case "+":
|
|
calcResult += val;
|
|
break;
|
|
case "-":
|
|
calcResult -= val;
|
|
break;
|
|
case "x":
|
|
calcResult *= val;
|
|
break;
|
|
case "÷":
|
|
calcResult = val !== 0 ? calcResult / val : calcResult;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 테이블로 계산 항목 렌더링
|
|
const calcTableRows = [];
|
|
|
|
// 각 항목
|
|
for (const item of calcItems) {
|
|
const itemValue = getCalcItemValueFn(
|
|
item as {
|
|
label: string;
|
|
value: number | string;
|
|
operator: string;
|
|
fieldName?: string;
|
|
}
|
|
);
|
|
calcTableRows.push(
|
|
new TableRowRef({
|
|
children: [
|
|
new TableCellRef({
|
|
children: [
|
|
new ParagraphRef({
|
|
children: [
|
|
new TextRunRef({
|
|
text: item.label,
|
|
size: calcLabelFontSize,
|
|
color: calcLabelColor,
|
|
font: "맑은 고딕",
|
|
}),
|
|
],
|
|
}),
|
|
],
|
|
width: {
|
|
size: pxToTwipFn(calcLabelWidth),
|
|
type: WidthType.DXA,
|
|
},
|
|
borders: {
|
|
top: {
|
|
style: BorderStyleRef.NONE,
|
|
size: 0,
|
|
color: "FFFFFF",
|
|
},
|
|
bottom: {
|
|
style: BorderStyleRef.NONE,
|
|
size: 0,
|
|
color: "FFFFFF",
|
|
},
|
|
left: {
|
|
style: BorderStyleRef.NONE,
|
|
size: 0,
|
|
color: "FFFFFF",
|
|
},
|
|
right: {
|
|
style: BorderStyleRef.NONE,
|
|
size: 0,
|
|
color: "FFFFFF",
|
|
},
|
|
},
|
|
margins: { top: 50, bottom: 50, left: 100, right: 100 },
|
|
}),
|
|
new TableCellRef({
|
|
children: [
|
|
new ParagraphRef({
|
|
alignment: AlignmentTypeRef.RIGHT,
|
|
children: [
|
|
new TextRunRef({
|
|
text: formatNumberFn(itemValue),
|
|
size: calcValueFontSize,
|
|
color: calcValueColor,
|
|
font: "맑은 고딕",
|
|
}),
|
|
],
|
|
}),
|
|
],
|
|
borders: {
|
|
top: {
|
|
style: BorderStyleRef.NONE,
|
|
size: 0,
|
|
color: "FFFFFF",
|
|
},
|
|
bottom: {
|
|
style: BorderStyleRef.NONE,
|
|
size: 0,
|
|
color: "FFFFFF",
|
|
},
|
|
left: {
|
|
style: BorderStyleRef.NONE,
|
|
size: 0,
|
|
color: "FFFFFF",
|
|
},
|
|
right: {
|
|
style: BorderStyleRef.NONE,
|
|
size: 0,
|
|
color: "FFFFFF",
|
|
},
|
|
},
|
|
margins: { top: 50, bottom: 50, left: 100, right: 100 },
|
|
}),
|
|
],
|
|
})
|
|
);
|
|
}
|
|
|
|
// 구분선 행
|
|
calcTableRows.push(
|
|
new TableRowRef({
|
|
children: [
|
|
new TableCellRef({
|
|
columnSpan: 2,
|
|
children: [new ParagraphRef({ children: [] })],
|
|
borders: {
|
|
top: {
|
|
style: BorderStyleRef.SINGLE,
|
|
size: 8,
|
|
color: borderColor,
|
|
},
|
|
bottom: {
|
|
style: BorderStyleRef.NONE,
|
|
size: 0,
|
|
color: "FFFFFF",
|
|
},
|
|
left: {
|
|
style: BorderStyleRef.NONE,
|
|
size: 0,
|
|
color: "FFFFFF",
|
|
},
|
|
right: {
|
|
style: BorderStyleRef.NONE,
|
|
size: 0,
|
|
color: "FFFFFF",
|
|
},
|
|
},
|
|
}),
|
|
],
|
|
})
|
|
);
|
|
|
|
// 결과 행
|
|
calcTableRows.push(
|
|
new TableRowRef({
|
|
children: [
|
|
new TableCellRef({
|
|
children: [
|
|
new ParagraphRef({
|
|
children: [
|
|
new TextRunRef({
|
|
text: resultLabel,
|
|
size: calcResultFontSize,
|
|
color: calcLabelColor,
|
|
bold: true,
|
|
font: "맑은 고딕",
|
|
}),
|
|
],
|
|
}),
|
|
],
|
|
width: {
|
|
size: pxToTwipFn(calcLabelWidth),
|
|
type: WidthType.DXA,
|
|
},
|
|
borders: {
|
|
top: {
|
|
style: BorderStyleRef.NONE,
|
|
size: 0,
|
|
color: "FFFFFF",
|
|
},
|
|
bottom: {
|
|
style: BorderStyleRef.NONE,
|
|
size: 0,
|
|
color: "FFFFFF",
|
|
},
|
|
left: {
|
|
style: BorderStyleRef.NONE,
|
|
size: 0,
|
|
color: "FFFFFF",
|
|
},
|
|
right: {
|
|
style: BorderStyleRef.NONE,
|
|
size: 0,
|
|
color: "FFFFFF",
|
|
},
|
|
},
|
|
margins: { top: 50, bottom: 50, left: 100, right: 100 },
|
|
}),
|
|
new TableCellRef({
|
|
children: [
|
|
new ParagraphRef({
|
|
alignment: AlignmentTypeRef.RIGHT,
|
|
children: [
|
|
new TextRunRef({
|
|
text: formatNumberFn(calcResult),
|
|
size: calcResultFontSize,
|
|
color: calcResultColor,
|
|
bold: true,
|
|
font: "맑은 고딕",
|
|
}),
|
|
],
|
|
}),
|
|
],
|
|
borders: {
|
|
top: {
|
|
style: BorderStyleRef.NONE,
|
|
size: 0,
|
|
color: "FFFFFF",
|
|
},
|
|
bottom: {
|
|
style: BorderStyleRef.NONE,
|
|
size: 0,
|
|
color: "FFFFFF",
|
|
},
|
|
left: {
|
|
style: BorderStyleRef.NONE,
|
|
size: 0,
|
|
color: "FFFFFF",
|
|
},
|
|
right: {
|
|
style: BorderStyleRef.NONE,
|
|
size: 0,
|
|
color: "FFFFFF",
|
|
},
|
|
},
|
|
margins: { top: 50, bottom: 50, left: 100, right: 100 },
|
|
}),
|
|
],
|
|
})
|
|
);
|
|
|
|
result.push(
|
|
new TableRef({
|
|
rows: calcTableRows,
|
|
width: { size: pxToTwipFn(component.width), type: WidthType.DXA },
|
|
borders: {
|
|
top: { style: BorderStyleRef.NONE, size: 0, color: "FFFFFF" },
|
|
bottom: {
|
|
style: BorderStyleRef.NONE,
|
|
size: 0,
|
|
color: "FFFFFF",
|
|
},
|
|
left: { style: BorderStyleRef.NONE, size: 0, color: "FFFFFF" },
|
|
right: { style: BorderStyleRef.NONE, size: 0, color: "FFFFFF" },
|
|
insideHorizontal: {
|
|
style: BorderStyleRef.NONE,
|
|
size: 0,
|
|
color: "FFFFFF",
|
|
},
|
|
insideVertical: {
|
|
style: BorderStyleRef.NONE,
|
|
size: 0,
|
|
color: "FFFFFF",
|
|
},
|
|
},
|
|
})
|
|
);
|
|
}
|
|
|
|
// Barcode 컴포넌트 (바코드 이미지가 미리 생성되어 전달된 경우)
|
|
else if (component.type === "barcode" && component.barcodeImageBase64) {
|
|
try {
|
|
const base64Data =
|
|
component.barcodeImageBase64.split(",")[1] || component.barcodeImageBase64;
|
|
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) {
|
|
// 바코드 이미지 생성 실패 시 텍스트로 대체
|
|
const barcodeValue = component.barcodeValue || "BARCODE";
|
|
result.push(
|
|
new ParagraphRef({
|
|
children: [
|
|
new TextRunRef({
|
|
text: `[${barcodeValue}]`,
|
|
size: pxToHalfPtFn(12),
|
|
font: "맑은 고딕",
|
|
}),
|
|
],
|
|
})
|
|
);
|
|
}
|
|
}
|
|
|
|
// Checkbox 컴포넌트
|
|
else if (component.type === "checkbox") {
|
|
// 체크 상태 결정 (쿼리 바인딩 또는 고정값)
|
|
let isChecked = component.checkboxChecked === true;
|
|
if (component.checkboxFieldName && component.queryId && queryResultsMapRef[component.queryId]) {
|
|
const qResult = queryResultsMapRef[component.queryId];
|
|
if (qResult.rows && qResult.rows.length > 0) {
|
|
const row = qResult.rows[0];
|
|
const val = row[component.checkboxFieldName];
|
|
// truthy/falsy 값 판정
|
|
if (val === true || val === "true" || val === "Y" || val === 1 || val === "1") {
|
|
isChecked = true;
|
|
} else {
|
|
isChecked = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
const checkboxSymbol = isChecked ? "☑" : "☐";
|
|
const checkboxLabel = component.checkboxLabel || "";
|
|
const labelPosition = component.checkboxLabelPosition || "right";
|
|
const displayText = labelPosition === "left"
|
|
? `${checkboxLabel} ${checkboxSymbol}`
|
|
: `${checkboxSymbol} ${checkboxLabel}`;
|
|
|
|
result.push(
|
|
new ParagraphRef({
|
|
children: [
|
|
new TextRunRef({
|
|
text: displayText.trim(),
|
|
size: pxToHalfPtFn(component.fontSize || 14),
|
|
font: "맑은 고딕",
|
|
color: (component.fontColor || "#374151").replace("#", ""),
|
|
}),
|
|
],
|
|
})
|
|
);
|
|
}
|
|
|
|
// 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 generateBarcodeImage = async (
|
|
component: any,
|
|
queryResultsMapRef: Record<string, { fields: string[]; rows: Record<string, unknown>[] }>
|
|
): Promise<string | null> => {
|
|
try {
|
|
const barcodeType = component.barcodeType || "CODE128";
|
|
const barcodeColor = (component.barcodeColor || "#000000").replace("#", "");
|
|
// transparent는 bwip-js에서 지원하지 않으므로 흰색으로 변환
|
|
let barcodeBackground = (component.barcodeBackground || "#ffffff").replace("#", "");
|
|
if (barcodeBackground === "transparent" || barcodeBackground === "") {
|
|
barcodeBackground = "ffffff";
|
|
}
|
|
|
|
// 바코드 값 결정 (쿼리 바인딩 또는 고정값)
|
|
let barcodeValue = component.barcodeValue || "SAMPLE123";
|
|
|
|
// QR코드 다중 필드 모드
|
|
if (
|
|
barcodeType === "QR" &&
|
|
component.qrUseMultiField &&
|
|
component.qrDataFields &&
|
|
component.qrDataFields.length > 0 &&
|
|
component.queryId &&
|
|
queryResultsMapRef[component.queryId]
|
|
) {
|
|
const qResult = queryResultsMapRef[component.queryId];
|
|
if (qResult.rows && qResult.rows.length > 0) {
|
|
// 모든 행 포함 모드
|
|
if (component.qrIncludeAllRows) {
|
|
const allRowsData: Record<string, string>[] = [];
|
|
qResult.rows.forEach((row) => {
|
|
const rowData: Record<string, string> = {};
|
|
component.qrDataFields!.forEach((field: { fieldName: string; label: string }) => {
|
|
if (field.fieldName && field.label) {
|
|
const val = row[field.fieldName];
|
|
rowData[field.label] = val !== null && val !== undefined ? String(val) : "";
|
|
}
|
|
});
|
|
allRowsData.push(rowData);
|
|
});
|
|
barcodeValue = JSON.stringify(allRowsData);
|
|
} else {
|
|
// 단일 행 (첫 번째 행만)
|
|
const row = qResult.rows[0];
|
|
const jsonData: Record<string, string> = {};
|
|
component.qrDataFields.forEach((field: { fieldName: string; label: string }) => {
|
|
if (field.fieldName && field.label) {
|
|
const val = row[field.fieldName];
|
|
jsonData[field.label] = val !== null && val !== undefined ? String(val) : "";
|
|
}
|
|
});
|
|
barcodeValue = JSON.stringify(jsonData);
|
|
}
|
|
}
|
|
}
|
|
// 단일 필드 바인딩
|
|
else if (component.barcodeFieldName && component.queryId && queryResultsMapRef[component.queryId]) {
|
|
const qResult = queryResultsMapRef[component.queryId];
|
|
if (qResult.rows && qResult.rows.length > 0) {
|
|
// QR코드 + 모든 행 포함
|
|
if (barcodeType === "QR" && component.qrIncludeAllRows) {
|
|
const allValues = qResult.rows
|
|
.map((row) => {
|
|
const val = row[component.barcodeFieldName!];
|
|
return val !== null && val !== undefined ? String(val) : "";
|
|
})
|
|
.filter((v) => v !== "");
|
|
barcodeValue = JSON.stringify(allValues);
|
|
} else {
|
|
// 단일 행 (첫 번째 행만)
|
|
const row = qResult.rows[0];
|
|
const val = row[component.barcodeFieldName];
|
|
if (val !== null && val !== undefined) {
|
|
barcodeValue = String(val);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// bwip-js 바코드 타입 매핑
|
|
const bcidMap: Record<string, string> = {
|
|
"CODE128": "code128",
|
|
"CODE39": "code39",
|
|
"EAN13": "ean13",
|
|
"EAN8": "ean8",
|
|
"UPC": "upca",
|
|
"QR": "qrcode",
|
|
};
|
|
|
|
const bcid = bcidMap[barcodeType] || "code128";
|
|
const isQR = barcodeType === "QR";
|
|
|
|
// 바코드 옵션 설정
|
|
const options: any = {
|
|
bcid: bcid,
|
|
text: barcodeValue,
|
|
scale: 3,
|
|
includetext: !isQR && component.showBarcodeText !== false,
|
|
textxalign: "center",
|
|
barcolor: barcodeColor,
|
|
backgroundcolor: barcodeBackground,
|
|
};
|
|
|
|
// QR 코드 옵션
|
|
if (isQR) {
|
|
options.eclevel = component.qrErrorCorrectionLevel || "M";
|
|
}
|
|
|
|
// 바코드 이미지 생성
|
|
const png = await bwipjs.toBuffer(options);
|
|
const base64 = png.toString("base64");
|
|
return `data:image/png;base64,${base64}`;
|
|
} catch (error) {
|
|
console.error("바코드 생성 오류:", error);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
// 모든 페이지의 바코드 컴포넌트에 대해 이미지 생성
|
|
for (const page of layoutConfig.pages) {
|
|
if (page.components) {
|
|
for (const component of page.components) {
|
|
if (component.type === "barcode") {
|
|
const barcodeImage = await generateBarcodeImage(component, queryResultsMap);
|
|
if (barcodeImage) {
|
|
component.barcodeImageBase64 = barcodeImage;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 섹션 생성 (페이지별)
|
|
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 },
|
|
layout: TableLayoutType.FIXED, // 셀 너비 고정
|
|
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 },
|
|
layout: TableLayoutType.FIXED, // 셀 너비 고정
|
|
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 sigImageHeight = 30; // 고정 높이
|
|
const sigImageWidth = Math.round((component.width / component.height) * sigImageHeight) || 80;
|
|
|
|
const paragraph = new Paragraph({
|
|
spacing: { before: spacingBefore, after: 0 },
|
|
indent: { left: indentLeft },
|
|
children: [
|
|
...textRuns,
|
|
new ImageRun({
|
|
data: imageBuffer,
|
|
transformation: {
|
|
width: sigImageWidth,
|
|
height: sigImageHeight,
|
|
},
|
|
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;
|
|
}
|
|
|
|
// Card 컴포넌트 - 테이블로 감싸서 정확한 위치 적용
|
|
else if (component.type === "card") {
|
|
const cardTitle = component.cardTitle || "정보 카드";
|
|
const cardItems = component.cardItems || [];
|
|
const labelWidthPx = component.labelWidth || 80;
|
|
const showCardTitle = component.showCardTitle !== false;
|
|
const titleFontSize = pxToHalfPt(component.titleFontSize || 14);
|
|
const labelFontSizeCard = pxToHalfPt(component.labelFontSize || 13);
|
|
const valueFontSizeCard = pxToHalfPt(component.valueFontSize || 13);
|
|
const titleColorCard = (component.titleColor || "#1e40af").replace(
|
|
"#",
|
|
""
|
|
);
|
|
const labelColorCard = (component.labelColor || "#374151").replace(
|
|
"#",
|
|
""
|
|
);
|
|
const valueColorCard = (component.valueColor || "#000000").replace(
|
|
"#",
|
|
""
|
|
);
|
|
const borderColorCard = (
|
|
component.borderColor || "#e5e7eb"
|
|
).replace("#", "");
|
|
|
|
// 쿼리 바인딩된 값 가져오기
|
|
const getCardValueLocal = (item: {
|
|
label: string;
|
|
value: string;
|
|
fieldName?: string;
|
|
}) => {
|
|
if (
|
|
item.fieldName &&
|
|
component.queryId &&
|
|
queryResultsMap[component.queryId]
|
|
) {
|
|
const qResult = queryResultsMap[component.queryId];
|
|
if (qResult.rows && qResult.rows.length > 0) {
|
|
const row = qResult.rows[0];
|
|
return row[item.fieldName] !== undefined
|
|
? String(row[item.fieldName])
|
|
: item.value;
|
|
}
|
|
}
|
|
return item.value;
|
|
};
|
|
|
|
const cardParagraphs: Paragraph[] = [];
|
|
|
|
// 제목
|
|
if (showCardTitle) {
|
|
cardParagraphs.push(
|
|
new Paragraph({
|
|
children: [
|
|
new TextRun({
|
|
text: cardTitle,
|
|
size: titleFontSize,
|
|
color: titleColorCard,
|
|
bold: true,
|
|
font: "맑은 고딕",
|
|
}),
|
|
],
|
|
})
|
|
);
|
|
// 구분선
|
|
cardParagraphs.push(
|
|
new Paragraph({
|
|
border: {
|
|
bottom: {
|
|
color: borderColorCard,
|
|
space: 1,
|
|
style: BorderStyle.SINGLE,
|
|
size: 8,
|
|
},
|
|
},
|
|
children: [],
|
|
})
|
|
);
|
|
}
|
|
|
|
// 항목들을 테이블로 구성 (라벨 + 값)
|
|
const itemRows = cardItems.map(
|
|
(item: { label: string; value: string; fieldName?: string }) => {
|
|
const itemValue = getCardValueLocal(item);
|
|
return new TableRow({
|
|
children: [
|
|
new TableCell({
|
|
width: {
|
|
size: pxToTwip(labelWidthPx),
|
|
type: WidthType.DXA,
|
|
},
|
|
children: [
|
|
new Paragraph({
|
|
children: [
|
|
new TextRun({
|
|
text: item.label,
|
|
size: labelFontSizeCard,
|
|
color: labelColorCard,
|
|
bold: true,
|
|
font: "맑은 고딕",
|
|
}),
|
|
],
|
|
}),
|
|
],
|
|
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",
|
|
},
|
|
},
|
|
}),
|
|
new TableCell({
|
|
width: {
|
|
size: pxToTwip(component.width - labelWidthPx - 16),
|
|
type: WidthType.DXA,
|
|
},
|
|
children: [
|
|
new Paragraph({
|
|
children: [
|
|
new TextRun({
|
|
text: itemValue,
|
|
size: valueFontSizeCard,
|
|
color: valueColorCard,
|
|
font: "맑은 고딕",
|
|
}),
|
|
],
|
|
}),
|
|
],
|
|
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 itemsTable = new Table({
|
|
rows: itemRows,
|
|
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" },
|
|
insideHorizontal: {
|
|
style: BorderStyle.NONE,
|
|
size: 0,
|
|
color: "FFFFFF",
|
|
},
|
|
insideVertical: {
|
|
style: BorderStyle.NONE,
|
|
size: 0,
|
|
color: "FFFFFF",
|
|
},
|
|
},
|
|
});
|
|
|
|
// 전체를 하나의 테이블 셀로 감싸기
|
|
const cardCell = new TableCell({
|
|
children: [...cardParagraphs, itemsTable],
|
|
width: { size: pxToTwip(component.width), type: WidthType.DXA },
|
|
borders:
|
|
component.showCardBorder !== false
|
|
? {
|
|
top: {
|
|
style: BorderStyle.SINGLE,
|
|
size: 4,
|
|
color: borderColorCard,
|
|
},
|
|
bottom: {
|
|
style: BorderStyle.SINGLE,
|
|
size: 4,
|
|
color: borderColorCard,
|
|
},
|
|
left: {
|
|
style: BorderStyle.SINGLE,
|
|
size: 4,
|
|
color: borderColorCard,
|
|
},
|
|
right: {
|
|
style: BorderStyle.SINGLE,
|
|
size: 4,
|
|
color: borderColorCard,
|
|
},
|
|
}
|
|
: {
|
|
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 cardTable = new Table({
|
|
rows: [new TableRow({ children: [cardCell] })],
|
|
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(cardTable);
|
|
lastBottomY = adjustedY + component.height;
|
|
}
|
|
|
|
// 계산 컴포넌트 - 테이블로 감싸서 정확한 위치 적용
|
|
else if (component.type === "calculation") {
|
|
const calcItems = component.calcItems || [];
|
|
const resultLabel = component.resultLabel || "합계";
|
|
const calcLabelWidth = component.labelWidth || 120;
|
|
const calcLabelFontSize = pxToHalfPt(component.labelFontSize || 13);
|
|
const calcValueFontSize = pxToHalfPt(component.valueFontSize || 13);
|
|
const calcResultFontSize = pxToHalfPt(
|
|
component.resultFontSize || 16
|
|
);
|
|
const calcLabelColor = (component.labelColor || "#374151").replace(
|
|
"#",
|
|
""
|
|
);
|
|
const calcValueColor = (component.valueColor || "#000000").replace(
|
|
"#",
|
|
""
|
|
);
|
|
const calcResultColor = (
|
|
component.resultColor || "#2563eb"
|
|
).replace("#", "");
|
|
const numberFormat = component.numberFormat || "currency";
|
|
const currencySuffix = component.currencySuffix || "원";
|
|
const borderColor = (component.borderColor || "#374151").replace(
|
|
"#",
|
|
""
|
|
);
|
|
|
|
// 숫자 포맷팅 함수
|
|
const formatNumberFn = (num: number): string => {
|
|
if (numberFormat === "none") return String(num);
|
|
if (numberFormat === "comma") return num.toLocaleString();
|
|
if (numberFormat === "currency")
|
|
return num.toLocaleString() + currencySuffix;
|
|
return String(num);
|
|
};
|
|
|
|
// 쿼리 바인딩된 값 가져오기
|
|
const getCalcItemValueFn = (item: {
|
|
label: string;
|
|
value: number | string;
|
|
operator: string;
|
|
fieldName?: string;
|
|
}): number => {
|
|
if (
|
|
item.fieldName &&
|
|
component.queryId &&
|
|
queryResultsMap[component.queryId]
|
|
) {
|
|
const qResult = queryResultsMap[component.queryId];
|
|
if (qResult.rows && qResult.rows.length > 0) {
|
|
const row = qResult.rows[0];
|
|
const val = row[item.fieldName];
|
|
return typeof val === "number"
|
|
? val
|
|
: parseFloat(String(val)) || 0;
|
|
}
|
|
}
|
|
return typeof item.value === "number"
|
|
? item.value
|
|
: parseFloat(String(item.value)) || 0;
|
|
};
|
|
|
|
// 계산 결과 (첫 번째 항목은 기준값, 두 번째부터 연산자 적용)
|
|
let calcResult = 0;
|
|
if (calcItems.length > 0) {
|
|
// 첫 번째 항목은 기준값
|
|
calcResult = getCalcItemValueFn(
|
|
calcItems[0] as {
|
|
label: string;
|
|
value: number | string;
|
|
operator: string;
|
|
fieldName?: string;
|
|
}
|
|
);
|
|
|
|
// 두 번째 항목부터 연산자 적용
|
|
for (let i = 1; i < calcItems.length; i++) {
|
|
const calcItem = calcItems[i];
|
|
const val = getCalcItemValueFn(
|
|
calcItem as {
|
|
label: string;
|
|
value: number | string;
|
|
operator: string;
|
|
fieldName?: string;
|
|
}
|
|
);
|
|
switch ((calcItem as { operator: string }).operator) {
|
|
case "+":
|
|
calcResult += val;
|
|
break;
|
|
case "-":
|
|
calcResult -= val;
|
|
break;
|
|
case "x":
|
|
calcResult *= val;
|
|
break;
|
|
case "÷":
|
|
calcResult = val !== 0 ? calcResult / val : calcResult;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 테이블 행 생성
|
|
const calcTableRows: TableRow[] = [];
|
|
|
|
// 각 항목 행
|
|
for (const calcItem of calcItems) {
|
|
const itemValue = getCalcItemValueFn(
|
|
calcItem as {
|
|
label: string;
|
|
value: number | string;
|
|
operator: string;
|
|
fieldName?: string;
|
|
}
|
|
);
|
|
calcTableRows.push(
|
|
new TableRow({
|
|
children: [
|
|
new TableCell({
|
|
children: [
|
|
new Paragraph({
|
|
children: [
|
|
new TextRun({
|
|
text: calcItem.label,
|
|
size: calcLabelFontSize,
|
|
color: calcLabelColor,
|
|
font: "맑은 고딕",
|
|
}),
|
|
],
|
|
}),
|
|
],
|
|
width: {
|
|
size: pxToTwip(calcLabelWidth),
|
|
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",
|
|
},
|
|
},
|
|
margins: { top: 50, bottom: 50, left: 100, right: 100 },
|
|
}),
|
|
new TableCell({
|
|
children: [
|
|
new Paragraph({
|
|
alignment: AlignmentType.RIGHT,
|
|
children: [
|
|
new TextRun({
|
|
text: formatNumberFn(itemValue),
|
|
size: calcValueFontSize,
|
|
color: calcValueColor,
|
|
font: "맑은 고딕",
|
|
}),
|
|
],
|
|
}),
|
|
],
|
|
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",
|
|
},
|
|
},
|
|
margins: { top: 50, bottom: 50, left: 100, right: 100 },
|
|
}),
|
|
],
|
|
})
|
|
);
|
|
}
|
|
|
|
// 구분선 행
|
|
calcTableRows.push(
|
|
new TableRow({
|
|
children: [
|
|
new TableCell({
|
|
columnSpan: 2,
|
|
children: [new Paragraph({ children: [] })],
|
|
borders: {
|
|
top: {
|
|
style: BorderStyle.SINGLE,
|
|
size: 8,
|
|
color: borderColor,
|
|
},
|
|
bottom: {
|
|
style: BorderStyle.NONE,
|
|
size: 0,
|
|
color: "FFFFFF",
|
|
},
|
|
left: {
|
|
style: BorderStyle.NONE,
|
|
size: 0,
|
|
color: "FFFFFF",
|
|
},
|
|
right: {
|
|
style: BorderStyle.NONE,
|
|
size: 0,
|
|
color: "FFFFFF",
|
|
},
|
|
},
|
|
}),
|
|
],
|
|
})
|
|
);
|
|
|
|
// 결과 행
|
|
calcTableRows.push(
|
|
new TableRow({
|
|
children: [
|
|
new TableCell({
|
|
children: [
|
|
new Paragraph({
|
|
children: [
|
|
new TextRun({
|
|
text: resultLabel,
|
|
size: calcResultFontSize,
|
|
color: calcLabelColor,
|
|
bold: true,
|
|
font: "맑은 고딕",
|
|
}),
|
|
],
|
|
}),
|
|
],
|
|
width: {
|
|
size: pxToTwip(calcLabelWidth),
|
|
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",
|
|
},
|
|
},
|
|
margins: { top: 50, bottom: 50, left: 100, right: 100 },
|
|
}),
|
|
new TableCell({
|
|
children: [
|
|
new Paragraph({
|
|
alignment: AlignmentType.RIGHT,
|
|
children: [
|
|
new TextRun({
|
|
text: formatNumberFn(calcResult),
|
|
size: calcResultFontSize,
|
|
color: calcResultColor,
|
|
bold: true,
|
|
font: "맑은 고딕",
|
|
}),
|
|
],
|
|
}),
|
|
],
|
|
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",
|
|
},
|
|
},
|
|
margins: { top: 50, bottom: 50, left: 100, right: 100 },
|
|
}),
|
|
],
|
|
})
|
|
);
|
|
|
|
const calcTable = new Table({
|
|
rows: calcTableRows,
|
|
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(calcTable);
|
|
lastBottomY = adjustedY + component.height;
|
|
}
|
|
|
|
// Barcode 컴포넌트
|
|
else if (component.type === "barcode") {
|
|
if (component.barcodeImageBase64) {
|
|
try {
|
|
const base64Data =
|
|
component.barcodeImageBase64.split(",")[1] || component.barcodeImageBase64;
|
|
const imageBuffer = Buffer.from(base64Data, "base64");
|
|
|
|
// spacing을 위한 빈 paragraph
|
|
if (spacingBefore > 0) {
|
|
children.push(
|
|
new Paragraph({
|
|
spacing: { before: spacingBefore, after: 0 },
|
|
children: [],
|
|
})
|
|
);
|
|
}
|
|
|
|
children.push(
|
|
new Paragraph({
|
|
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",
|
|
}),
|
|
],
|
|
})
|
|
);
|
|
} catch (imgError) {
|
|
console.error("바코드 이미지 오류:", imgError);
|
|
// 바코드 이미지 생성 실패 시 텍스트로 대체
|
|
const barcodeValue = component.barcodeValue || "BARCODE";
|
|
children.push(
|
|
new Paragraph({
|
|
spacing: { before: spacingBefore, after: 0 },
|
|
indent: { left: indentLeft },
|
|
children: [
|
|
new TextRun({
|
|
text: `[${barcodeValue}]`,
|
|
size: pxToHalfPt(12),
|
|
font: "맑은 고딕",
|
|
}),
|
|
],
|
|
})
|
|
);
|
|
}
|
|
} else {
|
|
// 바코드 이미지가 없는 경우 텍스트로 대체
|
|
const barcodeValue = component.barcodeValue || "BARCODE";
|
|
children.push(
|
|
new Paragraph({
|
|
spacing: { before: spacingBefore, after: 0 },
|
|
indent: { left: indentLeft },
|
|
children: [
|
|
new TextRun({
|
|
text: `[${barcodeValue}]`,
|
|
size: pxToHalfPt(12),
|
|
font: "맑은 고딕",
|
|
}),
|
|
],
|
|
})
|
|
);
|
|
}
|
|
lastBottomY = adjustedY + component.height;
|
|
}
|
|
|
|
// Checkbox 컴포넌트
|
|
else if (component.type === "checkbox") {
|
|
// 체크 상태 결정 (쿼리 바인딩 또는 고정값)
|
|
let isChecked = component.checkboxChecked === true;
|
|
if (component.checkboxFieldName && component.queryId && queryResultsMap[component.queryId]) {
|
|
const qResult = queryResultsMap[component.queryId];
|
|
if (qResult.rows && qResult.rows.length > 0) {
|
|
const row = qResult.rows[0];
|
|
const val = row[component.checkboxFieldName];
|
|
// truthy/falsy 값 판정
|
|
if (val === true || val === "true" || val === "Y" || val === 1 || val === "1") {
|
|
isChecked = true;
|
|
} else {
|
|
isChecked = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
const checkboxSymbol = isChecked ? "☑" : "☐";
|
|
const checkboxLabel = component.checkboxLabel || "";
|
|
const labelPosition = component.checkboxLabelPosition || "right";
|
|
const displayText = labelPosition === "left"
|
|
? `${checkboxLabel} ${checkboxSymbol}`
|
|
: `${checkboxSymbol} ${checkboxLabel}`;
|
|
|
|
// spacing을 위한 빈 paragraph
|
|
if (spacingBefore > 0) {
|
|
children.push(
|
|
new Paragraph({
|
|
spacing: { before: spacingBefore, after: 0 },
|
|
children: [],
|
|
})
|
|
);
|
|
}
|
|
|
|
children.push(
|
|
new Paragraph({
|
|
indent: { left: indentLeft },
|
|
children: [
|
|
new TextRun({
|
|
text: displayText.trim(),
|
|
size: pxToHalfPt(component.fontSize || 14),
|
|
font: "맑은 고딕",
|
|
color: (component.fontColor || "#374151").replace("#", ""),
|
|
}),
|
|
],
|
|
})
|
|
);
|
|
|
|
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<string, unknown>) =>
|
|
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: [] }));
|
|
}
|
|
|
|
// 워터마크 헤더 생성 (전체 페이지 공유 워터마크)
|
|
const watermark: WatermarkConfig | undefined = layoutConfig.watermark;
|
|
let headers: { default?: Header } | undefined;
|
|
|
|
if (watermark?.enabled && watermark.type === "text" && watermark.text) {
|
|
// 워터마크 색상을 hex로 변환 (alpha 적용)
|
|
const opacity = watermark.opacity ?? 0.3;
|
|
const fontColor = watermark.fontColor || "#CCCCCC";
|
|
// hex 색상에서 # 제거
|
|
const cleanColor = fontColor.replace("#", "");
|
|
|
|
headers = {
|
|
default: new Header({
|
|
children: [
|
|
new Paragraph({
|
|
alignment: AlignmentType.CENTER,
|
|
children: [
|
|
new TextRun({
|
|
text: watermark.text,
|
|
size: (watermark.fontSize || 48) * 2, // Word는 half-point 사용
|
|
color: cleanColor,
|
|
bold: true,
|
|
}),
|
|
],
|
|
}),
|
|
],
|
|
}),
|
|
};
|
|
}
|
|
|
|
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,
|
|
},
|
|
},
|
|
},
|
|
headers,
|
|
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();
|