ERP-node/frontend/components/report/designer/renderers/TableRenderer.tsx

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} />;
}