322 lines
11 KiB
TypeScript
322 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import type { TableRendererProps } from "./types";
|
|
import type { ComponentConfig, GridCell } from "@/types/report";
|
|
|
|
type TableColumn = NonNullable<ComponentConfig["tableColumns"]>[number];
|
|
|
|
// ─── 헬퍼 함수 ────────────────────────────────────────────────────────────────
|
|
|
|
function applyNumberFormat(value: string, format?: string, suffix?: string): string {
|
|
if (!format || format === "none") return value;
|
|
const num = parseFloat(value.replace(/,/g, ""));
|
|
if (isNaN(num)) return value;
|
|
const formatted = num.toLocaleString("ko-KR");
|
|
return format === "currency" ? `${formatted}${suffix ?? "원"}` : formatted;
|
|
}
|
|
|
|
function getCellValue(col: TableColumn, row: Record<string, unknown>): string {
|
|
const raw = String(row[col.field] ?? "");
|
|
return applyNumberFormat(raw, col.numberFormat, col.currencySuffix);
|
|
}
|
|
|
|
function calcSummary(col: TableColumn, rows: Record<string, unknown>[]): string {
|
|
if (!col.summaryType || col.summaryType === "NONE") return "";
|
|
|
|
if (col.summaryType === "COUNT") {
|
|
return applyNumberFormat(String(rows.length), col.numberFormat, col.currencySuffix);
|
|
}
|
|
|
|
const values = rows
|
|
.map((row) => {
|
|
const raw = String(row[col.field] ?? "");
|
|
return parseFloat(raw.replace(/,/g, ""));
|
|
})
|
|
.filter((v) => !isNaN(v));
|
|
|
|
if (values.length === 0) return "";
|
|
|
|
const sum = values.reduce((a, b) => a + b, 0);
|
|
const result = col.summaryType === "AVG" ? sum / values.length : sum;
|
|
|
|
return applyNumberFormat(
|
|
parseFloat(result.toFixed(4)).toString(),
|
|
col.numberFormat,
|
|
col.currencySuffix,
|
|
);
|
|
}
|
|
|
|
// ─── 그리드 셀 값 계산 ──────────────────────────────────────────────────────
|
|
|
|
function getGridCellValue(
|
|
cell: GridCell,
|
|
row?: Record<string, unknown>,
|
|
): string {
|
|
if (cell.cellType === "static") return cell.value ?? "";
|
|
|
|
if (cell.cellType === "field" && cell.field && row) {
|
|
const raw = String(row[cell.field] ?? "");
|
|
return applyNumberFormat(raw, cell.numberFormat, cell.currencySuffix);
|
|
}
|
|
|
|
return cell.value ?? "";
|
|
}
|
|
|
|
// ─── 그리드 테이블 렌더러 ────────────────────────────────────────────────────
|
|
|
|
function GridTableRenderer({ component, getQueryResult }: TableRendererProps) {
|
|
const cells = component.gridCells ?? [];
|
|
const rowCount = component.gridRowCount ?? 0;
|
|
const colCount = component.gridColCount ?? 0;
|
|
const colWidths = component.gridColWidths ?? [];
|
|
const rowHeights = component.gridRowHeights ?? [];
|
|
const headerRows = component.gridHeaderRows ?? 1;
|
|
const headerCols = component.gridHeaderCols ?? 1;
|
|
|
|
if (cells.length === 0 || rowCount === 0 || colCount === 0) {
|
|
return (
|
|
<div className="flex h-full w-full items-center justify-center border-2 border-dashed border-gray-300 bg-gray-50 text-xs text-gray-400">
|
|
격자 양식을 구성하세요
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const resultKey = component.visualQuery?.tableName
|
|
? `visual_${component.id}`
|
|
: component.queryId;
|
|
const queryResult = resultKey ? getQueryResult(resultKey) : null;
|
|
const dataRow = queryResult?.rows?.[0] as Record<string, unknown> | undefined;
|
|
|
|
const totalConfigWidth = colWidths.reduce((a, b) => a + b, 0) || 1;
|
|
const totalConfigHeight = rowHeights.reduce((a, b) => a + b, 0) || 1;
|
|
|
|
const hdrBg = component.headerBackgroundColor || "#f3f4f6";
|
|
const hdrColor = component.headerTextColor || "#111827";
|
|
|
|
const findCell = (row: number, col: number) =>
|
|
cells.find((c) => c.row === row && c.col === col);
|
|
|
|
const tableRows: React.ReactNode[] = [];
|
|
|
|
for (let r = 0; r < rowCount; r++) {
|
|
const tds: React.ReactNode[] = [];
|
|
const rowHPct = ((rowHeights[r] ?? 32) / totalConfigHeight) * 100;
|
|
|
|
for (let c = 0; c < colCount; c++) {
|
|
const cell = findCell(r, c);
|
|
if (!cell || cell.merged) continue;
|
|
|
|
const rSpan = cell.rowSpan ?? 1;
|
|
const cSpan = cell.colSpan ?? 1;
|
|
const borderW =
|
|
cell.borderStyle === "medium" ? 2 : cell.borderStyle === "thick" ? 3 : cell.borderStyle === "none" ? 0 : 1;
|
|
|
|
const isHeader = r < headerRows || c < headerCols;
|
|
const cellBg = cell.backgroundColor || (isHeader ? hdrBg : "white");
|
|
const cellColor = cell.textColor || (isHeader ? hdrColor : "#111827");
|
|
|
|
const displayValue = getGridCellValue(cell, dataRow);
|
|
|
|
tds.push(
|
|
<td
|
|
key={cell.id}
|
|
rowSpan={rSpan > 1 ? rSpan : undefined}
|
|
colSpan={cSpan > 1 ? cSpan : undefined}
|
|
style={{
|
|
backgroundColor: cellBg,
|
|
border: `${borderW}px solid #d1d5db`,
|
|
padding: "2px 4px",
|
|
fontSize: cell.fontSize ?? 12,
|
|
fontWeight: cell.fontWeight === "bold" ? 700 : (isHeader ? 600 : 400),
|
|
color: cellColor,
|
|
textAlign: cell.align || "center",
|
|
verticalAlign: cell.verticalAlign || "middle",
|
|
overflow: "hidden",
|
|
whiteSpace: "pre-line",
|
|
wordBreak: "break-word",
|
|
}}
|
|
>
|
|
{displayValue}
|
|
</td>,
|
|
);
|
|
}
|
|
|
|
tableRows.push(
|
|
<tr key={`grid-row-${r}`} style={{ height: `${rowHPct.toFixed(2)}%` }}>
|
|
{tds}
|
|
</tr>,
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="h-full w-full overflow-hidden">
|
|
<table
|
|
className="border-collapse"
|
|
style={{ width: "100%", height: "100%", tableLayout: "fixed" }}
|
|
>
|
|
<colgroup>
|
|
{colWidths.map((w, i) => {
|
|
const pct = (w / totalConfigWidth) * 100;
|
|
return <col key={i} style={{ width: `${pct.toFixed(2)}%` }} />;
|
|
})}
|
|
</colgroup>
|
|
<tbody>{tableRows}</tbody>
|
|
</table>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── 기존 테이블 렌더러 ─────────────────────────────────────────────────────
|
|
|
|
function ClassicTableRenderer({ component, getQueryResult }: TableRendererProps) {
|
|
const resultKey = component.visualQuery?.tableName
|
|
? `visual_${component.id}`
|
|
: component.queryId;
|
|
|
|
const queryResult = resultKey ? getQueryResult(resultKey) : null;
|
|
const hasData = queryResult && queryResult.rows.length > 0;
|
|
const hasColumns = component.tableColumns && component.tableColumns.length > 0;
|
|
|
|
if (!hasData && !hasColumns) {
|
|
return (
|
|
<div className="flex h-full w-full items-center justify-center border-2 border-dashed border-gray-300 bg-gray-50 text-xs text-gray-400">
|
|
테이블을 구성하세요
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const allColumns = hasColumns
|
|
? component.tableColumns!
|
|
: queryResult!.fields.map((field) => ({
|
|
field,
|
|
header: field,
|
|
width: undefined as number | undefined,
|
|
align: "left" as const,
|
|
visible: true,
|
|
}));
|
|
|
|
const visibleColumns = allColumns.filter((col) => col.visible !== false);
|
|
const dataRows = hasData ? queryResult!.rows : [];
|
|
const previewRowCount = component.tableRowCount ?? 3;
|
|
|
|
const hasSummaryRow =
|
|
component.showFooter &&
|
|
visibleColumns.some((col) => col.summaryType && col.summaryType !== "NONE");
|
|
|
|
const borderClass = component.showBorder !== false ? "border border-gray-300" : "";
|
|
const rowH = component.rowHeight ?? 28;
|
|
|
|
// 열 너비: 설정된 비율을 유지하면서 컴포넌트 전체 너비에 맞게 스케일
|
|
const totalConfigWidth = visibleColumns.reduce((s, c) => s + (c.width || 120), 0);
|
|
|
|
return (
|
|
<div className="h-full w-full overflow-hidden">
|
|
<table className="w-full border-collapse text-xs" style={{ tableLayout: "fixed" }}>
|
|
<colgroup>
|
|
{visibleColumns.map((col, idx) => {
|
|
const ratio = (col.width || 120) / totalConfigWidth;
|
|
return <col key={idx} style={{ width: `${(ratio * 100).toFixed(2)}%` }} />;
|
|
})}
|
|
</colgroup>
|
|
<thead>
|
|
<tr
|
|
style={{
|
|
backgroundColor: component.headerBackgroundColor || "#f3f4f6",
|
|
color: component.headerTextColor || "#111827",
|
|
}}
|
|
>
|
|
{visibleColumns.map((col, idx) => (
|
|
<th
|
|
key={`h_${col.field || col.header}_${idx}`}
|
|
className={borderClass}
|
|
style={{
|
|
padding: "4px 6px",
|
|
textAlign: col.align || "left",
|
|
fontWeight: "600",
|
|
overflow: "hidden",
|
|
textOverflow: "ellipsis",
|
|
whiteSpace: "nowrap",
|
|
}}
|
|
>
|
|
{col.header}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{dataRows.length > 0
|
|
? dataRows.map((row, rowIdx) => (
|
|
<tr key={rowIdx}>
|
|
{visibleColumns.map((col, colIdx) => (
|
|
<td
|
|
key={`r${rowIdx}_c${col.field || col.header}_${colIdx}`}
|
|
className={borderClass}
|
|
style={{
|
|
padding: "4px 6px",
|
|
textAlign: col.align || "left",
|
|
height: `${rowH}px`,
|
|
overflow: "hidden",
|
|
textOverflow: "ellipsis",
|
|
whiteSpace: "nowrap",
|
|
}}
|
|
>
|
|
{getCellValue(col, row)}
|
|
</td>
|
|
))}
|
|
</tr>
|
|
))
|
|
: Array.from({ length: previewRowCount }).map((_, rowIdx) => (
|
|
<tr key={`empty-${rowIdx}`}>
|
|
{visibleColumns.map((col, colIdx) => (
|
|
<td
|
|
key={`e${rowIdx}_${colIdx}`}
|
|
className={borderClass}
|
|
style={{
|
|
padding: "4px 6px",
|
|
textAlign: col.align || "left",
|
|
height: `${rowH}px`,
|
|
color: "#d1d5db",
|
|
}}
|
|
>
|
|
{col.field ? `{${col.field}}` : "—"}
|
|
</td>
|
|
))}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
|
|
{hasSummaryRow && dataRows.length > 0 && (
|
|
<tfoot>
|
|
<tr
|
|
style={{
|
|
backgroundColor: "#f3f4f6",
|
|
fontWeight: 600,
|
|
borderTop: "2px solid #d1d5db",
|
|
}}
|
|
>
|
|
{visibleColumns.map((col, idx) => (
|
|
<td
|
|
key={`f_${col.field || col.header}_${idx}`}
|
|
className={borderClass}
|
|
style={{ padding: "4px 6px", textAlign: col.align || "right" }}
|
|
>
|
|
{calcSummary(col, dataRows)}
|
|
</td>
|
|
))}
|
|
</tr>
|
|
</tfoot>
|
|
)}
|
|
</table>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── 메인 export ─────────────────────────────────────────────────────────────
|
|
|
|
export function TableRenderer(props: TableRendererProps) {
|
|
if (props.component.gridMode) {
|
|
return <GridTableRenderer {...props} />;
|
|
}
|
|
return <ClassicTableRenderer {...props} />;
|
|
}
|