diff --git a/backend-node/src/controllers/reportController.ts b/backend-node/src/controllers/reportController.ts
index e59235d9..d629864a 100644
--- a/backend-node/src/controllers/reportController.ts
+++ b/backend-node/src/controllers/reportController.ts
@@ -791,6 +791,93 @@ export class ReportController {
);
}
+ // 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: "맑은 고딕",
+ }),
+ ],
+ })
+ );
+ }
+ }
+
// Divider - 테이블 셀로 감싸서 정확한 너비 적용
else if (component.type === "divider" && component.orientation === "horizontal") {
result.push(
@@ -1279,6 +1366,172 @@ export class ReportController {
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;
+ }
+
// Table 컴포넌트
else if (component.type === "table" && component.queryId) {
const queryResult = queryResultsMap[component.queryId];
diff --git a/frontend/components/report/designer/CanvasComponent.tsx b/frontend/components/report/designer/CanvasComponent.tsx
index 90d8f21a..bd602582 100644
--- a/frontend/components/report/designer/CanvasComponent.tsx
+++ b/frontend/components/report/designer/CanvasComponent.tsx
@@ -602,6 +602,81 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
);
+ case "card":
+ // 카드 컴포넌트: 제목 + 항목 목록
+ const cardTitle = component.cardTitle || "정보 카드";
+ const cardItems = component.cardItems || [];
+ const labelWidth = component.labelWidth || 80;
+ const showCardTitle = component.showCardTitle !== false;
+ const titleFontSize = component.titleFontSize || 14;
+ const labelFontSize = component.labelFontSize || 13;
+ const valueFontSize = component.valueFontSize || 13;
+ const titleColor = component.titleColor || "#1e40af";
+ const labelColor = component.labelColor || "#374151";
+ const valueColor = component.valueColor || "#000000";
+
+ // 쿼리 바인딩된 값 가져오기
+ const getCardItemValue = (item: { label: string; value: string; fieldName?: string }) => {
+ if (item.fieldName && component.queryId) {
+ const queryResult = getQueryResult(component.queryId);
+ if (queryResult && queryResult.rows && queryResult.rows.length > 0) {
+ const row = queryResult.rows[0];
+ return row[item.fieldName] !== undefined ? String(row[item.fieldName]) : item.value;
+ }
+ }
+ return item.value;
+ };
+
+ return (
+
+ {/* 제목 */}
+ {showCardTitle && (
+ <>
+
+ {cardTitle}
+
+ {/* 구분선 */}
+
+ >
+ )}
+ {/* 항목 목록 */}
+
+ {cardItems.map((item: { label: string; value: string; fieldName?: string }, index: number) => (
+
+
+ {item.label}
+
+
+ {getCardItemValue(item)}
+
+
+ ))}
+
+
+ );
+
default:
return 알 수 없는 컴포넌트
;
}
diff --git a/frontend/components/report/designer/ComponentPalette.tsx b/frontend/components/report/designer/ComponentPalette.tsx
index c21ca6ec..ed989a58 100644
--- a/frontend/components/report/designer/ComponentPalette.tsx
+++ b/frontend/components/report/designer/ComponentPalette.tsx
@@ -1,7 +1,7 @@
"use client";
import { useDrag } from "react-dnd";
-import { Type, Table, Image, Minus, PenLine, Stamp as StampIcon, Hash } from "lucide-react";
+import { Type, Table, Image, Minus, PenLine, Stamp as StampIcon, Hash, CreditCard } from "lucide-react";
interface ComponentItem {
type: string;
@@ -17,6 +17,7 @@ const COMPONENTS: ComponentItem[] = [
{ type: "signature", label: "서명란", icon: },
{ type: "stamp", label: "도장란", icon: },
{ type: "pageNumber", label: "페이지번호", icon: },
+ { type: "card", label: "정보카드", icon: },
];
function DraggableComponentItem({ type, label, icon }: ComponentItem) {
diff --git a/frontend/components/report/designer/QueryManager.tsx b/frontend/components/report/designer/QueryManager.tsx
index 0fd25f43..e9ccb813 100644
--- a/frontend/components/report/designer/QueryManager.tsx
+++ b/frontend/components/report/designer/QueryManager.tsx
@@ -201,7 +201,8 @@ export function QueryManager() {
setIsTestRunning({ ...isTestRunning, [query.id]: true });
try {
const testReportId = reportId === "new" ? "TEMP_TEST" : reportId;
- const sqlQuery = reportId === "new" ? query.sqlQuery : undefined;
+ // 항상 sqlQuery를 전달 (새 쿼리가 아직 DB에 저장되지 않았을 수 있음)
+ const sqlQuery = query.sqlQuery;
const externalConnectionId = (query as any).externalConnectionId || null;
const queryParams = parameterValues[query.id] || {};
diff --git a/frontend/components/report/designer/ReportDesignerCanvas.tsx b/frontend/components/report/designer/ReportDesignerCanvas.tsx
index 655a80c0..09b0fa42 100644
--- a/frontend/components/report/designer/ReportDesignerCanvas.tsx
+++ b/frontend/components/report/designer/ReportDesignerCanvas.tsx
@@ -151,6 +151,28 @@ export function ReportDesignerCanvas() {
pageNumberFormat: "number" as const, // number, numberTotal, koreanNumber
textAlign: "center" as const,
}),
+ // 카드 컴포넌트 전용
+ ...(item.componentType === "card" && {
+ width: 300,
+ height: 180,
+ cardTitle: "정보 카드",
+ showCardTitle: true,
+ cardItems: [
+ { label: "항목1", value: "내용1", fieldName: "" },
+ { label: "항목2", value: "내용2", fieldName: "" },
+ { label: "항목3", value: "내용3", fieldName: "" },
+ ],
+ labelWidth: 80,
+ showCardBorder: true,
+ titleFontSize: 14,
+ labelFontSize: 13,
+ valueFontSize: 13,
+ titleColor: "#1e40af",
+ labelColor: "#374151",
+ valueColor: "#000000",
+ borderWidth: 1,
+ borderColor: "#e5e7eb",
+ }),
// 테이블 전용
...(item.componentType === "table" && {
queryId: undefined,
diff --git a/frontend/components/report/designer/ReportDesignerRightPanel.tsx b/frontend/components/report/designer/ReportDesignerRightPanel.tsx
index 0bfc2d31..fa4c72e5 100644
--- a/frontend/components/report/designer/ReportDesignerRightPanel.tsx
+++ b/frontend/components/report/designer/ReportDesignerRightPanel.tsx
@@ -28,6 +28,7 @@ export function ReportDesignerRightPanel() {
currentPage,
currentPageId,
updatePageSettings,
+ getQueryResult,
} = context;
const [activeTab, setActiveTab] = useState("properties");
const [uploadingImage, setUploadingImage] = useState(false);
@@ -950,6 +951,327 @@ export function ReportDesignerRightPanel() {
)}
+ {/* 카드 컴포넌트 설정 */}
+ {selectedComponent.type === "card" && (
+
+
+ 카드 설정
+
+
+ {/* 제목 표시 여부 */}
+
+
+ updateComponent(selectedComponent.id, {
+ showCardTitle: e.target.checked,
+ })
+ }
+ className="h-4 w-4"
+ />
+
+
+
+ {/* 제목 텍스트 */}
+ {selectedComponent.showCardTitle !== false && (
+
+
+
+ updateComponent(selectedComponent.id, {
+ cardTitle: e.target.value,
+ })
+ }
+ placeholder="정보 카드"
+ className="h-8"
+ />
+
+ )}
+
+ {/* 라벨 너비 */}
+
+
+
+ updateComponent(selectedComponent.id, {
+ labelWidth: Number(e.target.value),
+ })
+ }
+ min={40}
+ max={200}
+ className="h-8"
+ />
+
+
+ {/* 테두리 표시 */}
+
+
+ updateComponent(selectedComponent.id, {
+ showCardBorder: e.target.checked,
+ borderWidth: e.target.checked ? 1 : 0,
+ })
+ }
+ className="h-4 w-4"
+ />
+
+
+
+ {/* 폰트 크기 설정 */}
+
+
+ {/* 색상 설정 */}
+
+
+ {/* 항목 목록 관리 */}
+
+
+
+
+
+
+ {/* 쿼리 선택 (데이터 바인딩용) */}
+
+
+
+
+
+ {/* 항목 리스트 */}
+
+ {(selectedComponent.cardItems || []).map(
+ (item: { label: string; value: string; fieldName?: string }, index: number) => (
+
+
+ 항목 {index + 1}
+
+
+
+
+
+ {
+ const currentItems = [...(selectedComponent.cardItems || [])];
+ currentItems[index] = { ...item, label: e.target.value };
+ updateComponent(selectedComponent.id, {
+ cardItems: currentItems,
+ });
+ }}
+ className="h-6 text-xs"
+ placeholder="항목명"
+ />
+
+ {selectedComponent.queryId ? (
+
+
+
+
+ ) : (
+
+
+ {
+ const currentItems = [...(selectedComponent.cardItems || [])];
+ currentItems[index] = { ...item, value: e.target.value };
+ updateComponent(selectedComponent.id, {
+ cardItems: currentItems,
+ });
+ }}
+ className="h-6 text-xs"
+ placeholder="내용"
+ />
+
+ )}
+
+
+ ),
+ )}
+
+
+
+
+ )}
+
{/* 데이터 바인딩 (텍스트/라벨/테이블 컴포넌트) */}
{(selectedComponent.type === "text" ||
selectedComponent.type === "label" ||
diff --git a/frontend/components/report/designer/ReportPreviewModal.tsx b/frontend/components/report/designer/ReportPreviewModal.tsx
index 4d23e78b..6e0e80da 100644
--- a/frontend/components/report/designer/ReportPreviewModal.tsx
+++ b/frontend/components/report/designer/ReportPreviewModal.tsx
@@ -161,6 +161,58 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
content = `${pageNumberText}
`;
}
+ // 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 = component.titleFontSize || 14;
+ const labelFontSize = component.labelFontSize || 13;
+ const valueFontSize = component.valueFontSize || 13;
+ const titleColor = component.titleColor || "#1e40af";
+ const labelColor = component.labelColor || "#374151";
+ const valueColor = component.valueColor || "#000000";
+ const borderColor = component.borderColor || "#e5e7eb";
+
+ // 쿼리 바인딩된 값 가져오기
+ const getCardValue = (item: { label: string; value: string; fieldName?: string }) => {
+ if (item.fieldName && queryResult && queryResult.rows && queryResult.rows.length > 0) {
+ const row = queryResult.rows[0];
+ return row[item.fieldName] !== undefined ? String(row[item.fieldName]) : item.value;
+ }
+ return item.value;
+ };
+
+ const itemsHtml = cardItems
+ .map(
+ (item: { label: string; value: string; fieldName?: string }) => `
+
+ ${item.label}
+ ${getCardValue(item)}
+
+ `
+ )
+ .join("");
+
+ content = `
+
+ ${
+ showCardTitle
+ ? `
+
+ ${cardTitle}
+
+
+ `
+ : ""
+ }
+
+ ${itemsHtml}
+
+
`;
+ }
+
// Table 컴포넌트
else if (component.type === "table" && queryResult && queryResult.rows.length > 0) {
const columns =
@@ -764,6 +816,93 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
);
})()}
+
+ {/* Card 컴포넌트 */}
+ {component.type === "card" && (() => {
+ const cardTitle = component.cardTitle || "정보 카드";
+ const cardItems = component.cardItems || [];
+ const labelWidth = component.labelWidth || 80;
+ const showCardTitle = component.showCardTitle !== false;
+ const titleFontSize = component.titleFontSize || 14;
+ const labelFontSize = component.labelFontSize || 13;
+ const valueFontSize = component.valueFontSize || 13;
+ const titleColor = component.titleColor || "#1e40af";
+ const labelColor = component.labelColor || "#374151";
+ const valueColor = component.valueColor || "#000000";
+ const borderColor = component.borderColor || "#e5e7eb";
+
+ // 쿼리 바인딩된 값 가져오기
+ const getCardValue = (item: { label: string; value: string; fieldName?: string }) => {
+ if (item.fieldName && component.queryId) {
+ const qResult = getQueryResult(component.queryId);
+ if (qResult && 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;
+ };
+
+ return (
+
+ {showCardTitle && (
+ <>
+
+ {cardTitle}
+
+
+ >
+ )}
+
+ {cardItems.map((item: { label: string; value: string; fieldName?: string }, idx: number) => (
+
+
+ {item.label}
+
+
+ {getCardValue(item)}
+
+
+ ))}
+
+
+ );
+ })()}
);
})}
diff --git a/frontend/types/report.ts b/frontend/types/report.ts
index c31c49fa..1711bec6 100644
--- a/frontend/types/report.ts
+++ b/frontend/types/report.ts
@@ -160,6 +160,22 @@ export interface ComponentConfig {
rowHeight?: number; // 행 높이 (px)
// 페이지 번호 전용
pageNumberFormat?: "number" | "numberTotal" | "koreanNumber"; // 페이지 번호 포맷
+ // 카드 컴포넌트 전용
+ cardTitle?: string; // 카드 제목
+ cardItems?: Array<{
+ label: string; // 항목 라벨 (예: "회사명")
+ value: string; // 항목 값 (예: "당사 주식회사") 또는 기본값
+ fieldName?: string; // 쿼리 필드명 (바인딩용)
+ }>;
+ labelWidth?: number; // 라벨 컬럼 너비 (px)
+ showCardBorder?: boolean; // 카드 테두리 표시 여부
+ showCardTitle?: boolean; // 카드 제목 표시 여부
+ titleFontSize?: number; // 제목 폰트 크기
+ labelFontSize?: number; // 라벨 폰트 크기
+ valueFontSize?: number; // 값 폰트 크기
+ titleColor?: string; // 제목 색상
+ labelColor?: string; // 라벨 색상
+ valueColor?: string; // 값 색상
}
// 리포트 상세