카드 컴포넌트 추가 및 페이지번호/쿼리 버그 수정
This commit is contained in:
parent
0ed8e686c0
commit
1fd428c016
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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] || {};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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" ||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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; // 값 색상
|
||||
}
|
||||
|
||||
// 리포트 상세
|
||||
|
|
|
|||
Loading…
Reference in New Issue