카드 컴포넌트 추가 및 페이지번호/쿼리 버그 수정

This commit is contained in:
dohyeons 2025-12-18 10:39:57 +09:00
parent 0ed8e686c0
commit 1fd428c016
8 changed files with 831 additions and 2 deletions

View File

@ -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];

View File

@ -602,6 +602,81 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
</div>
);
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 (
<div className="flex h-full w-full flex-col overflow-hidden">
{/* 제목 */}
{showCardTitle && (
<>
<div
className="flex-shrink-0 px-2 py-1 font-semibold"
style={{
fontSize: `${titleFontSize}px`,
color: titleColor,
}}
>
{cardTitle}
</div>
{/* 구분선 */}
<div
className="mx-1 flex-shrink-0 border-b"
style={{ borderColor: component.borderColor || "#e5e7eb" }}
/>
</>
)}
{/* 항목 목록 */}
<div className="flex-1 overflow-auto px-2 py-1">
{cardItems.map((item: { label: string; value: string; fieldName?: string }, index: number) => (
<div key={index} className="flex py-0.5">
<span
className="flex-shrink-0 font-medium"
style={{
width: `${labelWidth}px`,
fontSize: `${labelFontSize}px`,
color: labelColor,
}}
>
{item.label}
</span>
<span
className="flex-1"
style={{
fontSize: `${valueFontSize}px`,
color: valueColor,
}}
>
{getCardItemValue(item)}
</span>
</div>
))}
</div>
</div>
);
default:
return <div> </div>;
}

View File

@ -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: <PenLine className="h-4 w-4" /> },
{ type: "stamp", label: "도장란", icon: <StampIcon className="h-4 w-4" /> },
{ type: "pageNumber", label: "페이지번호", icon: <Hash className="h-4 w-4" /> },
{ type: "card", label: "정보카드", icon: <CreditCard className="h-4 w-4" /> },
];
function DraggableComponentItem({ type, label, icon }: ComponentItem) {

View File

@ -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] || {};

View File

@ -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,

View File

@ -28,6 +28,7 @@ export function ReportDesignerRightPanel() {
currentPage,
currentPageId,
updatePageSettings,
getQueryResult,
} = context;
const [activeTab, setActiveTab] = useState<string>("properties");
const [uploadingImage, setUploadingImage] = useState(false);
@ -950,6 +951,327 @@ export function ReportDesignerRightPanel() {
</Card>
)}
{/* 카드 컴포넌트 설정 */}
{selectedComponent.type === "card" && (
<Card className="mt-4 border-teal-200 bg-teal-50">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-teal-900"> </CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{/* 제목 표시 여부 */}
<div className="flex items-center gap-2">
<input
type="checkbox"
id="showCardTitle"
checked={selectedComponent.showCardTitle !== false}
onChange={(e) =>
updateComponent(selectedComponent.id, {
showCardTitle: e.target.checked,
})
}
className="h-4 w-4"
/>
<Label htmlFor="showCardTitle" className="text-xs">
</Label>
</div>
{/* 제목 텍스트 */}
{selectedComponent.showCardTitle !== false && (
<div>
<Label className="text-xs"> </Label>
<Input
type="text"
value={selectedComponent.cardTitle || ""}
onChange={(e) =>
updateComponent(selectedComponent.id, {
cardTitle: e.target.value,
})
}
placeholder="정보 카드"
className="h-8"
/>
</div>
)}
{/* 라벨 너비 */}
<div>
<Label className="text-xs"> (px)</Label>
<Input
type="number"
value={selectedComponent.labelWidth || 80}
onChange={(e) =>
updateComponent(selectedComponent.id, {
labelWidth: Number(e.target.value),
})
}
min={40}
max={200}
className="h-8"
/>
</div>
{/* 테두리 표시 */}
<div className="flex items-center gap-2">
<input
type="checkbox"
id="showCardBorder"
checked={selectedComponent.showCardBorder !== false}
onChange={(e) =>
updateComponent(selectedComponent.id, {
showCardBorder: e.target.checked,
borderWidth: e.target.checked ? 1 : 0,
})
}
className="h-4 w-4"
/>
<Label htmlFor="showCardBorder" className="text-xs">
</Label>
</div>
{/* 폰트 크기 설정 */}
<div className="grid grid-cols-3 gap-2">
<div>
<Label className="text-xs"> </Label>
<Input
type="number"
value={selectedComponent.titleFontSize || 14}
onChange={(e) =>
updateComponent(selectedComponent.id, {
titleFontSize: Number(e.target.value),
})
}
min={10}
max={24}
className="h-8"
/>
</div>
<div>
<Label className="text-xs"> </Label>
<Input
type="number"
value={selectedComponent.labelFontSize || 13}
onChange={(e) =>
updateComponent(selectedComponent.id, {
labelFontSize: Number(e.target.value),
})
}
min={10}
max={20}
className="h-8"
/>
</div>
<div>
<Label className="text-xs"> </Label>
<Input
type="number"
value={selectedComponent.valueFontSize || 13}
onChange={(e) =>
updateComponent(selectedComponent.id, {
valueFontSize: Number(e.target.value),
})
}
min={10}
max={20}
className="h-8"
/>
</div>
</div>
{/* 색상 설정 */}
<div className="grid grid-cols-3 gap-2">
<div>
<Label className="text-xs"> </Label>
<Input
type="color"
value={selectedComponent.titleColor || "#1e40af"}
onChange={(e) =>
updateComponent(selectedComponent.id, {
titleColor: e.target.value,
})
}
className="h-8 w-full cursor-pointer p-1"
/>
</div>
<div>
<Label className="text-xs"> </Label>
<Input
type="color"
value={selectedComponent.labelColor || "#374151"}
onChange={(e) =>
updateComponent(selectedComponent.id, {
labelColor: e.target.value,
})
}
className="h-8 w-full cursor-pointer p-1"
/>
</div>
<div>
<Label className="text-xs"> </Label>
<Input
type="color"
value={selectedComponent.valueColor || "#000000"}
onChange={(e) =>
updateComponent(selectedComponent.id, {
valueColor: e.target.value,
})
}
className="h-8 w-full cursor-pointer p-1"
/>
</div>
</div>
{/* 항목 목록 관리 */}
<div className="mt-4 border-t pt-3">
<div className="mb-2 flex items-center justify-between">
<Label className="text-xs font-semibold"> </Label>
<Button
size="sm"
variant="outline"
className="h-6 text-xs"
onClick={() => {
const currentItems = selectedComponent.cardItems || [];
updateComponent(selectedComponent.id, {
cardItems: [
...currentItems,
{ label: `항목${currentItems.length + 1}`, value: "", fieldName: "" },
],
});
}}
>
+
</Button>
</div>
{/* 쿼리 선택 (데이터 바인딩용) */}
<div className="mb-2">
<Label className="text-xs"> ()</Label>
<Select
value={selectedComponent.queryId || "none"}
onValueChange={(value) =>
updateComponent(selectedComponent.id, {
queryId: value === "none" ? undefined : value,
})
}
>
<SelectTrigger className="h-8">
<SelectValue placeholder="쿼리 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"> </SelectItem>
{queries.map((q) => (
<SelectItem key={q.id} value={q.id}>
{q.name} ({q.type})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 항목 리스트 */}
<div className="max-h-48 space-y-2 overflow-y-auto">
{(selectedComponent.cardItems || []).map(
(item: { label: string; value: string; fieldName?: string }, index: number) => (
<div key={index} className="rounded border bg-white p-2">
<div className="mb-1 flex items-center justify-between">
<span className="text-xs font-medium"> {index + 1}</span>
<Button
size="sm"
variant="ghost"
className="h-5 w-5 p-0 text-red-500 hover:text-red-700"
onClick={() => {
const currentItems = [...(selectedComponent.cardItems || [])];
currentItems.splice(index, 1);
updateComponent(selectedComponent.id, {
cardItems: currentItems,
});
}}
>
x
</Button>
</div>
<div className="grid grid-cols-2 gap-1">
<div>
<Label className="text-[10px]"></Label>
<Input
type="text"
value={item.label}
onChange={(e) => {
const currentItems = [...(selectedComponent.cardItems || [])];
currentItems[index] = { ...item, label: e.target.value };
updateComponent(selectedComponent.id, {
cardItems: currentItems,
});
}}
className="h-6 text-xs"
placeholder="항목명"
/>
</div>
{selectedComponent.queryId ? (
<div>
<Label className="text-[10px]"></Label>
<Select
value={item.fieldName || "none"}
onValueChange={(value) => {
const currentItems = [...(selectedComponent.cardItems || [])];
currentItems[index] = {
...item,
fieldName: value === "none" ? "" : value,
};
updateComponent(selectedComponent.id, {
cardItems: currentItems,
});
}}
>
<SelectTrigger className="h-6 text-xs">
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"> </SelectItem>
{(() => {
const query = queries.find((q) => q.id === selectedComponent.queryId);
const result = query ? getQueryResult(query.id) : null;
if (result && result.fields) {
return result.fields.map((field: string) => (
<SelectItem key={field} value={field}>
{field}
</SelectItem>
));
}
return null;
})()}
</SelectContent>
</Select>
</div>
) : (
<div>
<Label className="text-[10px]"></Label>
<Input
type="text"
value={item.value}
onChange={(e) => {
const currentItems = [...(selectedComponent.cardItems || [])];
currentItems[index] = { ...item, value: e.target.value };
updateComponent(selectedComponent.id, {
cardItems: currentItems,
});
}}
className="h-6 text-xs"
placeholder="내용"
/>
</div>
)}
</div>
</div>
),
)}
</div>
</div>
</CardContent>
</Card>
)}
{/* 데이터 바인딩 (텍스트/라벨/테이블 컴포넌트) */}
{(selectedComponent.type === "text" ||
selectedComponent.type === "label" ||

View File

@ -161,6 +161,58 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
content = `<div style="display: flex; align-items: center; justify-content: center; height: 100%; font-size: ${component.fontSize}px; color: ${component.fontColor}; font-weight: ${component.fontWeight}; text-align: ${component.textAlign};">${pageNumberText}</div>`;
}
// 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 }) => `
<div style="display: flex; padding: 2px 0;">
<span style="width: ${labelWidth}px; flex-shrink: 0; font-size: ${labelFontSize}px; color: ${labelColor}; font-weight: 500;">${item.label}</span>
<span style="flex: 1; font-size: ${valueFontSize}px; color: ${valueColor};">${getCardValue(item)}</span>
</div>
`
)
.join("");
content = `
<div style="display: flex; flex-direction: column; height: 100%; overflow: hidden;">
${
showCardTitle
? `
<div style="flex-shrink: 0; padding: 4px 8px; font-size: ${titleFontSize}px; font-weight: 600; color: ${titleColor};">
${cardTitle}
</div>
<div style="flex-shrink: 0; margin: 0 4px; border-bottom: 1px solid ${borderColor};"></div>
`
: ""
}
<div style="flex: 1; padding: 4px 8px; overflow: auto;">
${itemsHtml}
</div>
</div>`;
}
// Table 컴포넌트
else if (component.type === "table" && queryResult && queryResult.rows.length > 0) {
const columns =
@ -764,6 +816,93 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
</div>
);
})()}
{/* 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 (
<div
style={{
display: "flex",
flexDirection: "column",
height: "100%",
overflow: "hidden",
}}
>
{showCardTitle && (
<>
<div
style={{
flexShrink: 0,
padding: "4px 8px",
fontSize: `${titleFontSize}px`,
fontWeight: 600,
color: titleColor,
}}
>
{cardTitle}
</div>
<div
style={{
flexShrink: 0,
margin: "0 4px",
borderBottom: `1px solid ${borderColor}`,
}}
/>
</>
)}
<div style={{ flex: 1, padding: "4px 8px", overflow: "auto" }}>
{cardItems.map((item: { label: string; value: string; fieldName?: string }, idx: number) => (
<div key={idx} style={{ display: "flex", padding: "2px 0" }}>
<span
style={{
width: `${labelWidth}px`,
flexShrink: 0,
fontSize: `${labelFontSize}px`,
color: labelColor,
fontWeight: 500,
}}
>
{item.label}
</span>
<span
style={{
flex: 1,
fontSize: `${valueFontSize}px`,
color: valueColor,
}}
>
{getCardValue(item)}
</span>
</div>
))}
</div>
</div>
);
})()}
</div>
);
})}

View File

@ -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; // 값 색상
}
// 리포트 상세