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" + /> + +
+ + {/* 폰트 크기 설정 */} +
+
+ + + updateComponent(selectedComponent.id, { + titleFontSize: Number(e.target.value), + }) + } + min={10} + max={24} + className="h-8" + /> +
+
+ + + updateComponent(selectedComponent.id, { + labelFontSize: Number(e.target.value), + }) + } + min={10} + max={20} + className="h-8" + /> +
+
+ + + updateComponent(selectedComponent.id, { + valueFontSize: Number(e.target.value), + }) + } + min={10} + max={20} + className="h-8" + /> +
+
+ + {/* 색상 설정 */} +
+
+ + + updateComponent(selectedComponent.id, { + titleColor: e.target.value, + }) + } + className="h-8 w-full cursor-pointer p-1" + /> +
+
+ + + updateComponent(selectedComponent.id, { + labelColor: e.target.value, + }) + } + className="h-8 w-full cursor-pointer p-1" + /> +
+
+ + + updateComponent(selectedComponent.id, { + valueColor: e.target.value, + }) + } + className="h-8 w-full cursor-pointer p-1" + /> +
+
+ + {/* 항목 목록 관리 */} +
+
+ + +
+ + {/* 쿼리 선택 (데이터 바인딩용) */} +
+ + +
+ + {/* 항목 리스트 */} +
+ {(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; // 값 색상 } // 리포트 상세