테이블 데이터 바인딩
This commit is contained in:
parent
dfa642798e
commit
7d801c0a2b
|
|
@ -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>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
{/* 기본값 (텍스트/라벨만) */}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
// 리포트 상세
|
||||
|
|
|
|||
Loading…
Reference in New Issue