테이블 데이터 바인딩

This commit is contained in:
dohyeons 2025-10-01 18:04:38 +09:00
parent dfa642798e
commit 7d801c0a2b
6 changed files with 363 additions and 102 deletions

View File

@ -256,39 +256,70 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
const queryResult = getQueryResult(component.queryId);
if (queryResult && queryResult.rows.length > 0) {
// tableColumns가 없으면 자동 생성
const columns =
component.tableColumns && component.tableColumns.length > 0
? component.tableColumns
: queryResult.fields.map((field) => ({
field,
header: field,
width: undefined,
align: "left" as const,
}));
return (
<div className="h-full w-full overflow-auto">
<div className="mb-1 flex items-center justify-between text-xs text-gray-500">
<span> ( )</span>
<span className="text-blue-600"> </span>
<span></span>
<span className="text-blue-600"> ({queryResult.rows.length})</span>
</div>
<table className="w-full border-collapse text-xs">
<table
className="w-full border-collapse text-xs"
style={{
borderCollapse: component.showBorder !== false ? "collapse" : "separate",
}}
>
<thead>
<tr className="bg-gray-100">
{queryResult.fields.map((field) => (
<th key={field} className="border p-1">
{field}
<tr
style={{
backgroundColor: component.headerBackgroundColor || "#f3f4f6",
color: component.headerTextColor || "#111827",
}}
>
{columns.map((col) => (
<th
key={col.field}
className={component.showBorder !== false ? "border border-gray-300" : ""}
style={{
padding: "6px 8px",
textAlign: col.align || "left",
width: col.width ? `${col.width}px` : "auto",
fontWeight: "600",
}}
>
{col.header}
</th>
))}
</tr>
</thead>
<tbody>
{queryResult.rows.slice(0, 3).map((row, idx) => (
{queryResult.rows.map((row, idx) => (
<tr key={idx}>
{queryResult.fields.map((field) => (
<td key={field} className="border p-1">
{String(row[field] ?? "")}
{columns.map((col) => (
<td
key={col.field}
className={component.showBorder !== false ? "border border-gray-300" : ""}
style={{
padding: "6px 8px",
textAlign: col.align || "left",
height: component.rowHeight ? `${component.rowHeight}px` : "auto",
}}
>
{String(row[col.field] ?? "")}
</td>
))}
</tr>
))}
{queryResult.rows.length > 3 && (
<tr>
<td colSpan={queryResult.fields.length} className="border p-1 text-center text-gray-400">
... {queryResult.rows.length - 3}
</td>
</tr>
)}
</tbody>
</table>
</div>
@ -298,24 +329,11 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
// 기본 테이블 (데이터 없을 때)
return (
<div className="h-full w-full overflow-auto">
<div className="mb-1 text-xs text-gray-500"> ( )</div>
<table className="w-full border-collapse text-xs">
<thead>
<tr className="bg-gray-100">
<th className="border p-1"></th>
<th className="border p-1"></th>
<th className="border p-1"></th>
</tr>
</thead>
<tbody>
<tr>
<td className="border p-1">1</td>
<td className="border p-1">10</td>
<td className="border p-1">50,000</td>
</tr>
</tbody>
</table>
<div className="h-full w-full">
<div className="mb-1 text-xs text-gray-500"></div>
<div className="flex h-[calc(100%-20px)] items-center justify-center border-2 border-dashed border-gray-300 bg-gray-50 text-xs text-gray-400">
</div>
</div>
);

View File

@ -119,6 +119,15 @@ export function ReportDesignerCanvas() {
borderWidth: 1,
borderColor: "#cccccc",
}),
// 테이블 전용
...(item.componentType === "table" && {
queryId: undefined,
tableColumns: [],
headerBackgroundColor: "#f3f4f6",
headerTextColor: "#111827",
showBorder: true,
rowHeight: 32,
}),
};
addComponent(newComponent);

View File

@ -361,6 +361,105 @@ export function ReportDesignerRightPanel() {
</div>
</div>
{/* 테이블 스타일 */}
{selectedComponent.type === "table" && (
<Card className="mt-4 border-indigo-200 bg-indigo-50">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-indigo-900"> </CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{/* 헤더 배경색 */}
<div>
<Label className="text-xs"> </Label>
<div className="flex gap-2">
<Input
type="color"
value={selectedComponent.headerBackgroundColor || "#f3f4f6"}
onChange={(e) =>
updateComponent(selectedComponent.id, {
headerBackgroundColor: e.target.value,
})
}
className="h-8 w-16"
/>
<Input
type="text"
value={selectedComponent.headerBackgroundColor || "#f3f4f6"}
onChange={(e) =>
updateComponent(selectedComponent.id, {
headerBackgroundColor: e.target.value,
})
}
className="h-8 flex-1 font-mono text-xs"
/>
</div>
</div>
{/* 헤더 텍스트 색상 */}
<div>
<Label className="text-xs"> </Label>
<div className="flex gap-2">
<Input
type="color"
value={selectedComponent.headerTextColor || "#111827"}
onChange={(e) =>
updateComponent(selectedComponent.id, {
headerTextColor: e.target.value,
})
}
className="h-8 w-16"
/>
<Input
type="text"
value={selectedComponent.headerTextColor || "#111827"}
onChange={(e) =>
updateComponent(selectedComponent.id, {
headerTextColor: e.target.value,
})
}
className="h-8 flex-1 font-mono text-xs"
/>
</div>
</div>
{/* 테두리 표시 */}
<div className="flex items-center gap-2">
<input
type="checkbox"
id="showBorder"
checked={selectedComponent.showBorder !== false}
onChange={(e) =>
updateComponent(selectedComponent.id, {
showBorder: e.target.checked,
})
}
className="h-4 w-4"
/>
<Label htmlFor="showBorder" className="text-xs">
</Label>
</div>
{/* 행 높이 */}
<div>
<Label className="text-xs"> (px)</Label>
<Input
type="number"
min="20"
max="100"
value={selectedComponent.rowHeight || 32}
onChange={(e) =>
updateComponent(selectedComponent.id, {
rowHeight: parseInt(e.target.value),
})
}
className="h-8"
/>
</div>
</CardContent>
</Card>
)}
{/* 이미지 속성 */}
{selectedComponent.type === "image" && (
<Card className="mt-4 border-purple-200 bg-purple-50">
@ -782,11 +881,131 @@ export function ReportDesignerRightPanel() {
</div>
)}
{/* 테이블 안내 메시지 */}
{/* 테이블 컬럼 설정 */}
{selectedComponent.queryId && selectedComponent.type === "table" && (
<div className="rounded-md bg-blue-100 p-2 text-xs text-blue-800">
.
</div>
<Card className="border-green-200 bg-green-50">
<CardHeader className="pb-2">
<CardTitle className="text-xs text-green-900"> </CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<Button
size="sm"
variant="outline"
className="h-7 w-full text-xs"
onClick={() => {
const fields = getQueryFields(selectedComponent.queryId!);
if (fields.length > 0) {
const autoColumns = fields.map((field) => ({
field,
header: field,
align: "left" as const,
}));
updateComponent(selectedComponent.id, {
tableColumns: autoColumns,
});
}
}}
>
( )
</Button>
{selectedComponent.tableColumns && selectedComponent.tableColumns.length > 0 && (
<div className="mt-3 space-y-2">
{selectedComponent.tableColumns.map((col, idx) => (
<div key={idx} className="space-y-1 rounded border border-green-200 bg-white p-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-gray-700"> {idx + 1}</span>
<Button
size="sm"
variant="ghost"
className="h-5 w-5 p-0 text-red-500 hover:bg-red-50"
onClick={() => {
const newColumns = [...selectedComponent.tableColumns!];
newColumns.splice(idx, 1);
updateComponent(selectedComponent.id, {
tableColumns: newColumns,
});
}}
>
×
</Button>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-xs"></Label>
<Input
value={col.field}
onChange={(e) => {
const newColumns = [...selectedComponent.tableColumns!];
newColumns[idx].field = e.target.value;
updateComponent(selectedComponent.id, {
tableColumns: newColumns,
});
}}
className="h-7 text-xs"
/>
</div>
<div>
<Label className="text-xs"></Label>
<Input
value={col.header}
onChange={(e) => {
const newColumns = [...selectedComponent.tableColumns!];
newColumns[idx].header = e.target.value;
updateComponent(selectedComponent.id, {
tableColumns: newColumns,
});
}}
className="h-7 text-xs"
/>
</div>
<div>
<Label className="text-xs">(px)</Label>
<Input
type="number"
value={col.width || ""}
onChange={(e) => {
const newColumns = [...selectedComponent.tableColumns!];
newColumns[idx].width = e.target.value
? parseInt(e.target.value)
: undefined;
updateComponent(selectedComponent.id, {
tableColumns: newColumns,
});
}}
placeholder="자동"
className="h-7 text-xs"
/>
</div>
<div>
<Label className="text-xs"></Label>
<Select
value={col.align || "left"}
onValueChange={(value) => {
const newColumns = [...selectedComponent.tableColumns!];
newColumns[idx].align = value as "left" | "center" | "right";
updateComponent(selectedComponent.id, {
tableColumns: newColumns,
});
}}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="left"></SelectItem>
<SelectItem value="center"></SelectItem>
<SelectItem value="right"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
)}
{/* 기본값 (텍스트/라벨만) */}

View File

@ -297,28 +297,70 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
)}
{component.type === "table" && queryResult && queryResult.rows.length > 0 ? (
<table className="w-full border-collapse text-xs">
<thead>
<tr className="bg-gray-100">
{queryResult.fields.map((field) => (
<th key={field} className="border border-gray-300 p-1">
{field}
</th>
))}
</tr>
</thead>
<tbody>
{queryResult.rows.map((row, idx) => (
<tr key={idx}>
{queryResult.fields.map((field) => (
<td key={field} className="border border-gray-300 p-1">
{String(row[field] ?? "")}
</td>
(() => {
// tableColumns가 없으면 자동 생성
const columns =
component.tableColumns && component.tableColumns.length > 0
? component.tableColumns
: queryResult.fields.map((field) => ({
field,
header: field,
align: "left" as const,
}));
return (
<table
style={{
width: "100%",
borderCollapse: component.showBorder !== false ? "collapse" : "separate",
fontSize: "12px",
}}
>
<thead>
<tr
style={{
backgroundColor: component.headerBackgroundColor || "#f3f4f6",
color: component.headerTextColor || "#111827",
}}
>
{columns.map((col) => (
<th
key={col.field}
style={{
border: component.showBorder !== false ? "1px solid #d1d5db" : "none",
padding: "6px 8px",
textAlign: col.align || "left",
width: col.width ? `${col.width}px` : "auto",
fontWeight: "600",
}}
>
{col.header}
</th>
))}
</tr>
</thead>
<tbody>
{queryResult.rows.map((row, idx) => (
<tr key={idx}>
{columns.map((col) => (
<td
key={col.field}
style={{
border: component.showBorder !== false ? "1px solid #d1d5db" : "none",
padding: "6px 8px",
textAlign: col.align || "left",
height: component.rowHeight ? `${component.rowHeight}px` : "auto",
}}
>
{String(row[col.field] ?? "")}
</td>
))}
</tr>
))}
</tr>
))}
</tbody>
</table>
</tbody>
</table>
);
})()
) : component.type === "table" ? (
<div className="text-xs text-gray-400"> </div>
) : null}

View File

@ -97,22 +97,7 @@ function getTemplateLayout(templateId: string): TemplateLayout | null {
zIndex: 1,
},
],
queries: [
{
id: `query-${Date.now()}-1`,
name: "발주 헤더",
type: "MASTER",
sqlQuery: "SELECT order_no, order_date, supplier_name FROM orders WHERE order_no = $1",
parameters: ["$1"],
},
{
id: `query-${Date.now()}-2`,
name: "발주 품목",
type: "DETAIL",
sqlQuery: "SELECT item_name, quantity, unit_price FROM order_items WHERE order_no = $1",
parameters: ["$1"],
},
],
queries: [],
};
case "invoice":
@ -191,22 +176,7 @@ function getTemplateLayout(templateId: string): TemplateLayout | null {
defaultValue: "합계: 0원",
},
],
queries: [
{
id: `query-${Date.now()}-1`,
name: "청구 헤더",
type: "MASTER",
sqlQuery: "SELECT invoice_no, invoice_date, customer_name FROM invoices WHERE invoice_no = $1",
parameters: ["$1"],
},
{
id: `query-${Date.now()}-2`,
name: "청구 항목",
type: "DETAIL",
sqlQuery: "SELECT description, quantity, unit_price, amount FROM invoice_items WHERE invoice_no = $1",
parameters: ["$1"],
},
],
queries: [],
};
case "basic":
@ -243,15 +213,7 @@ function getTemplateLayout(templateId: string): TemplateLayout | null {
defaultValue: "내용을 입력하세요",
},
],
queries: [
{
id: `query-${Date.now()}-1`,
name: "기본 쿼리",
type: "MASTER",
sqlQuery: "SELECT * FROM table_name WHERE id = $1",
parameters: ["$1"],
},
],
queries: [],
};
default:

View File

@ -123,6 +123,17 @@ export interface ComponentConfig {
labelPosition?: "top" | "left" | "bottom" | "right"; // 레이블 위치
showUnderline?: boolean; // 서명란 밑줄 표시 여부
personName?: string; // 도장란 이름 (예: "홍길동")
// 테이블 전용
tableColumns?: Array<{
field: string; // 필드명
header: string; // 헤더 표시명
width?: number; // 컬럼 너비 (px)
align?: "left" | "center" | "right"; // 정렬
}>;
headerBackgroundColor?: string; // 헤더 배경색
headerTextColor?: string; // 헤더 텍스트 색상
showBorder?: boolean; // 테두리 표시
rowHeight?: number; // 행 높이 (px)
}
// 리포트 상세