452 lines
17 KiB
TypeScript
452 lines
17 KiB
TypeScript
"use client";
|
||
|
||
import React, { useState, useCallback } from "react";
|
||
import { ComponentRendererProps } from "@/types/component";
|
||
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||
import {
|
||
TableConfig,
|
||
TableDisplayMode,
|
||
TableColumn,
|
||
TableRowHeight,
|
||
} from "./types";
|
||
import { useTableData } from "./useTableData";
|
||
|
||
const VALID_MODES: TableDisplayMode[] = ["table", "split", "grouped", "pivot", "card"];
|
||
const ROW_HEIGHT_PRESETS: Record<TableRowHeight, string> = {
|
||
compact: "28px",
|
||
normal: "36px",
|
||
relaxed: "44px",
|
||
};
|
||
const DESIGN_PREVIEW_ROWS = 5;
|
||
|
||
export interface TableComponentProps extends ComponentRendererProps {
|
||
config?: TableConfig;
|
||
}
|
||
|
||
export const TableComponent: React.FC<TableComponentProps> = ({
|
||
component,
|
||
isDesignMode = false,
|
||
isSelected = false,
|
||
onClick,
|
||
onDragStart,
|
||
onDragEnd,
|
||
config,
|
||
className,
|
||
style,
|
||
...props
|
||
}) => {
|
||
// ─── 4경로 머지 ───
|
||
const fromProps: Partial<TableConfig> = {};
|
||
const p = props as any;
|
||
if (typeof p.displayMode === "string" && (VALID_MODES as string[]).includes(p.displayMode))
|
||
fromProps.displayMode = p.displayMode as TableDisplayMode;
|
||
if (Array.isArray(p.columns)) fromProps.columns = p.columns;
|
||
if (Array.isArray(p.fields)) fromProps.fields = p.fields;
|
||
if (typeof p.selectionMode === "string") fromProps.selectionMode = p.selectionMode;
|
||
if (typeof p.showCheckbox === "boolean") fromProps.showCheckbox = p.showCheckbox;
|
||
if (typeof p.showHeader === "boolean") fromProps.showHeader = p.showHeader;
|
||
if (typeof p.showFooter === "boolean") fromProps.showFooter = p.showFooter;
|
||
if (p.pagination && typeof p.pagination === "object") fromProps.pagination = p.pagination;
|
||
if (typeof p.rowHeight === "string") fromProps.rowHeight = p.rowHeight;
|
||
if (typeof p.striped === "boolean") fromProps.striped = p.striped;
|
||
if (typeof p.hoverable === "boolean") fromProps.hoverable = p.hoverable;
|
||
if (typeof p.bordered === "boolean") fromProps.bordered = p.bordered;
|
||
if (typeof p.splitRatio === "number") fromProps.splitRatio = p.splitRatio;
|
||
if (typeof p.groupBy === "string") fromProps.groupBy = p.groupBy;
|
||
if (typeof p.emptyMessage === "string") fromProps.emptyMessage = p.emptyMessage;
|
||
if (typeof p.showToolbar === "boolean") fromProps.showToolbar = p.showToolbar;
|
||
if (typeof p.showExcel === "boolean") fromProps.showExcel = p.showExcel;
|
||
if (typeof p.showRefresh === "boolean") fromProps.showRefresh = p.showRefresh;
|
||
|
||
const componentConfig = {
|
||
...config,
|
||
...((component as any).config ?? {}),
|
||
...((component as any).componentConfig ?? {}),
|
||
...fromProps,
|
||
} as TableConfig;
|
||
|
||
const displayMode: TableDisplayMode = (VALID_MODES as string[]).includes(
|
||
componentConfig.displayMode as string,
|
||
)
|
||
? (componentConfig.displayMode as TableDisplayMode)
|
||
: "table";
|
||
|
||
// ─── 테이블명 결정 ───
|
||
const tableName =
|
||
componentConfig.selectedTable ||
|
||
(component as any).tableName ||
|
||
(component as any).componentConfig?.selectedTable ||
|
||
(component as any).componentConfig?.tableName ||
|
||
(props as any).tableName;
|
||
|
||
// ─── columns 결정 ───
|
||
const columns: TableColumn[] = (() => {
|
||
if (Array.isArray(componentConfig.fields) && componentConfig.fields.length > 0) {
|
||
return componentConfig.fields
|
||
.filter((f) => f.visible !== false && !f.system)
|
||
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
||
.map<TableColumn>((f) => ({
|
||
key: f.column,
|
||
label: f.label,
|
||
width: f.width,
|
||
align: f.align ?? (f.type === "number" ? "right" : "left"),
|
||
sortable: f.sortable ?? true,
|
||
visible: f.visible !== false,
|
||
format: f.format,
|
||
}));
|
||
}
|
||
if (Array.isArray(componentConfig.columns) && componentConfig.columns.length > 0) {
|
||
return componentConfig.columns.filter((c) => c.visible !== false);
|
||
}
|
||
return [];
|
||
})();
|
||
|
||
const showHeader = componentConfig.showHeader !== false;
|
||
const showFooter = componentConfig.showFooter !== false;
|
||
const showCheckbox = componentConfig.showCheckbox ?? false;
|
||
const striped = componentConfig.striped ?? true;
|
||
const hoverable = componentConfig.hoverable ?? true;
|
||
const rowHeight = ROW_HEIGHT_PRESETS[componentConfig.rowHeight ?? "normal"];
|
||
const showToolbar = componentConfig.showToolbar ?? true;
|
||
const emptyMessage = componentConfig.emptyMessage ?? "데이터가 없습니다";
|
||
|
||
// ─── 외부 검색 파라미터 (Search 컴포넌트가 onSearch 로 업데이트) ───
|
||
const externalSearch =
|
||
(props as any).searchParams && typeof (props as any).searchParams === "object"
|
||
? ((props as any).searchParams as Record<string, any>)
|
||
: undefined;
|
||
|
||
// ─── 데이터 fetch ───
|
||
const tableData = useTableData({
|
||
tableName,
|
||
enabled: !!tableName,
|
||
pageSize: isDesignMode
|
||
? Math.min(componentConfig.pagination?.pageSize ?? DESIGN_PREVIEW_ROWS, DESIGN_PREVIEW_ROWS)
|
||
: componentConfig.pagination?.pageSize ?? 20,
|
||
search: isDesignMode ? undefined : externalSearch,
|
||
});
|
||
|
||
// ─── 행 선택 ───
|
||
const [selectedRowIdx, setSelectedRowIdx] = useState<number | null>(null);
|
||
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
|
||
|
||
const handleRowClick = useCallback((idx: number) => {
|
||
if (isDesignMode) return;
|
||
if (componentConfig.selectionMode === "multiple") {
|
||
setSelectedRows((prev) => {
|
||
const next = new Set(prev);
|
||
next.has(idx) ? next.delete(idx) : next.add(idx);
|
||
return next;
|
||
});
|
||
} else {
|
||
setSelectedRowIdx(idx);
|
||
}
|
||
}, [isDesignMode, componentConfig.selectionMode]);
|
||
|
||
// ─── 렌더할 데이터 결정 ───
|
||
const rows = isDesignMode
|
||
? (tableData.data.length > 0
|
||
? tableData.data.slice(0, DESIGN_PREVIEW_ROWS)
|
||
: (columns.length > 0 ? [{}, {}, {}] : []))
|
||
: tableData.data;
|
||
|
||
// ─── DOM props 필터 ───
|
||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||
const {
|
||
selectedScreen: _1, onZoneComponentDrop: _2, onZoneClick: _3,
|
||
componentConfig: _4, component: _5, isSelected: _6,
|
||
onClick: _7, onDragStart: _8, onDragEnd: _9,
|
||
size: _10, position: _11, style: _12,
|
||
screenId: _13, tableName: _14, onRefresh: _15, onClose: _16,
|
||
web_type: _17, autoGeneration: _18, isInteractive: _19,
|
||
formData: _20, onFormDataChange: _21,
|
||
menuId: _22, menuObjid: _23, onSave: _24,
|
||
userId: _25, userName: _26, companyCode: _27,
|
||
isInModal: _28, readonly: _29,
|
||
originalData: _30, _originalData: _31, _initialData: _32, _groupedData: _33,
|
||
allComponents: _34, onUpdateLayout: _35,
|
||
selectedRows: _36, selectedRowsData: _37, onSelectedRowsChange: _38,
|
||
sortBy: _39, sortOrder: _40, tableDisplayData: _41,
|
||
flowSelectedData: _42, flowSelectedStepId: _43,
|
||
onFlowSelectedDataChange: _44, onConfigChange: _45,
|
||
refreshKey: _46, flowRefreshKey: _47, onFlowRefresh: _48,
|
||
isPreview: _49, groupedData: _50,
|
||
displayMode: _51, columns: _52, fields: _53,
|
||
selectionMode: _54, showCheckbox: _55, showHeader: _56, showFooter: _57,
|
||
pagination: _58, rowHeight: _59, striped: _60, hoverable: _61, bordered: _62,
|
||
splitRatio: _63, groupBy: _64, pivotRows: _65, pivotColumns: _66, pivotValues: _67,
|
||
emptyMessage: _68, showToolbar: _69, showExcel: _70, showRefresh: _71,
|
||
disabled: _72, required: _73,
|
||
// Search ↔ Table 연동 props — DOM 에 흘리지 않음
|
||
onSearch: _74, searchParams: _75,
|
||
...domProps
|
||
} = props as any;
|
||
/* eslint-enable @typescript-eslint/no-unused-vars */
|
||
|
||
// ─── 스타일 ───
|
||
const containerStyle: React.CSSProperties = {
|
||
width: "100%",
|
||
height: "100%",
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
background: "hsl(var(--card))",
|
||
borderRadius: "6px",
|
||
border: "1px solid hsl(var(--border))",
|
||
overflow: "hidden",
|
||
...(component as any).style,
|
||
...style,
|
||
};
|
||
if (isDesignMode && isSelected) {
|
||
containerStyle.outline = "2px solid hsl(var(--primary))";
|
||
containerStyle.outlineOffset = "2px";
|
||
}
|
||
|
||
// ─── 서브 렌더 ───
|
||
const renderToolbar = () =>
|
||
showToolbar && (
|
||
<div style={toolbarStyle}>
|
||
<span style={{ fontSize: "11px", color: "hsl(var(--muted-foreground))", fontWeight: 600 }}>
|
||
{tableName ? tableName : displayMode.toUpperCase()}
|
||
{!isDesignMode && ` · ${tableData.total}건`}
|
||
{isDesignMode && tableName && ` · 미리보기 ${Math.min(rows.length, DESIGN_PREVIEW_ROWS)}건`}
|
||
{isDesignMode && !tableName && columns.length > 0 && ` · 컬럼 ${columns.length}개`}
|
||
</span>
|
||
<div style={{ display: "flex", gap: "4px" }}>
|
||
{tableData.loading && (
|
||
<span style={{ fontSize: "10px", color: "hsl(var(--primary))" }}>로딩...</span>
|
||
)}
|
||
{componentConfig.showRefresh && (
|
||
<button type="button" style={btnStyle} onClick={tableData.refresh} disabled={isDesignMode}>
|
||
⟳
|
||
</button>
|
||
)}
|
||
{componentConfig.showExcel && (
|
||
<button type="button" style={btnStyle} disabled>📊</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
const renderHeader = () =>
|
||
showHeader && (
|
||
<thead style={{ background: "hsl(var(--muted))", position: "sticky", top: 0, zIndex: 1 }}>
|
||
<tr>
|
||
{showCheckbox && (
|
||
<th style={{ ...thStyle, width: "32px", textAlign: "center" }}>
|
||
<input type="checkbox" disabled={isDesignMode} />
|
||
</th>
|
||
)}
|
||
{columns.map((col) => (
|
||
<th
|
||
key={col.key}
|
||
style={{
|
||
...thStyle,
|
||
width: col.width ? `${col.width}px` : undefined,
|
||
textAlign: col.align ?? "left",
|
||
cursor: col.sortable && !isDesignMode ? "pointer" : "default",
|
||
}}
|
||
onClick={() => col.sortable && !isDesignMode && tableData.toggleSort(col.key)}
|
||
>
|
||
{col.label}
|
||
{col.sortable && (
|
||
<span style={{ marginLeft: "4px", fontSize: "10px", color: tableData.sortBy === col.key ? "hsl(var(--primary))" : "hsl(var(--muted-foreground))" }}>
|
||
{tableData.sortBy === col.key ? (tableData.sortOrder === "asc" ? "↑" : "↓") : "↕"}
|
||
</span>
|
||
)}
|
||
</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
);
|
||
|
||
const renderRows = () => (
|
||
<tbody>
|
||
{rows.length > 0 ? rows.map((row, idx) => (
|
||
<tr
|
||
key={idx}
|
||
style={{
|
||
height: rowHeight,
|
||
background:
|
||
selectedRowIdx === idx
|
||
? "hsl(var(--primary) / 0.08)"
|
||
: striped && idx % 2 === 1
|
||
? "hsl(var(--muted) / 0.5)"
|
||
: "transparent",
|
||
borderBottom: "1px solid hsl(var(--border) / 0.3)",
|
||
cursor: hoverable && !isDesignMode ? "pointer" : "default",
|
||
transition: "background 0.1s",
|
||
}}
|
||
onClick={() => handleRowClick(idx)}
|
||
>
|
||
{showCheckbox && (
|
||
<td style={{ ...tdStyle, textAlign: "center" }}>
|
||
<input
|
||
type="checkbox"
|
||
checked={selectedRows.has(idx)}
|
||
onChange={() => handleRowClick(idx)}
|
||
disabled={isDesignMode}
|
||
/>
|
||
</td>
|
||
)}
|
||
{columns.map((col) => (
|
||
<td key={col.key} style={{ ...tdStyle, textAlign: col.align ?? "left" }}>
|
||
{isDesignMode ? (
|
||
<span style={{ color: "hsl(var(--muted-foreground))" }}>
|
||
{row[col.key] != null ? String(row[col.key]) : "..."}
|
||
</span>
|
||
) : (
|
||
<span>{row[col.key] != null ? String(row[col.key]) : ""}</span>
|
||
)}
|
||
</td>
|
||
))}
|
||
</tr>
|
||
)) : (
|
||
<tr>
|
||
<td
|
||
colSpan={columns.length + (showCheckbox ? 1 : 0) || 1}
|
||
style={{ padding: "24px", textAlign: "center", color: "hsl(var(--muted-foreground))", fontSize: "11px" }}
|
||
>
|
||
{!tableName
|
||
? "테이블을 연결하세요"
|
||
: columns.length === 0
|
||
? "컬럼을 자동 로드하세요"
|
||
: tableData.loading
|
||
? "로딩 중..."
|
||
: (tableData.error || emptyMessage)}
|
||
</td>
|
||
</tr>
|
||
)}
|
||
</tbody>
|
||
);
|
||
|
||
const renderFooter = () =>
|
||
showFooter && (
|
||
<div style={footerStyle}>
|
||
<span>{isDesignMode ? `미리보기 ${rows.length}건` : `총 ${tableData.total}건`}</span>
|
||
<div style={{ display: "flex", gap: "4px", alignItems: "center" }}>
|
||
<button
|
||
type="button"
|
||
style={btnStyle}
|
||
disabled={isDesignMode || tableData.page <= 1}
|
||
onClick={() => tableData.setPage(tableData.page - 1)}
|
||
>
|
||
‹
|
||
</button>
|
||
<span style={{ padding: "2px 8px", fontSize: "11px" }}>
|
||
{isDesignMode ? "1 / 1" : `${tableData.page} / ${tableData.totalPages}`}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
style={btnStyle}
|
||
disabled={isDesignMode || tableData.page >= tableData.totalPages}
|
||
onClick={() => tableData.setPage(tableData.page + 1)}
|
||
>
|
||
›
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
// ─── 메인 렌더 ───
|
||
const renderBasicTable = () => (
|
||
<div style={{ flex: 1, minHeight: 0, overflow: "auto" }}>
|
||
{columns.length > 0 ? (
|
||
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: "12px" }}>
|
||
{renderHeader()}
|
||
{renderRows()}
|
||
</table>
|
||
) : (
|
||
<div style={{ padding: "24px", textAlign: "center", color: "hsl(var(--muted-foreground))", fontSize: "11px" }}>
|
||
{!tableName
|
||
? "테이블을 연결하세요"
|
||
: isDesignMode
|
||
? "컬럼을 자동 로드하면 미리보기가 표시됩니다"
|
||
: emptyMessage}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
|
||
const renderSplitMode = () => {
|
||
const ratio = componentConfig.splitRatio ?? 0.5;
|
||
const selectedRow = selectedRowIdx != null ? rows[selectedRowIdx] : null;
|
||
return (
|
||
<div style={{ flex: 1, display: "flex", minHeight: 0 }}>
|
||
<div style={{ flex: ratio, borderRight: "1px solid hsl(var(--border))", minWidth: 0, overflow: "auto" }}>
|
||
{renderBasicTable()}
|
||
</div>
|
||
<div style={{ flex: 1 - ratio, padding: "10px", background: "hsl(var(--muted) / 0.3)", minWidth: 0, overflow: "auto" }}>
|
||
{selectedRow ? (
|
||
<div style={{ fontSize: "11px" }}>
|
||
{columns.map((col) => (
|
||
<div key={col.key} style={{ padding: "4px 0", borderBottom: "1px solid hsl(var(--border) / 0.2)" }}>
|
||
<span style={{ color: "hsl(var(--muted-foreground))", marginRight: "8px", fontWeight: 600, fontSize: "10px" }}>{col.label}</span>
|
||
<span>{selectedRow[col.key] != null ? String(selectedRow[col.key]) : "-"}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div style={{ textAlign: "center", color: "hsl(var(--muted-foreground))", fontSize: "11px", paddingTop: "20px" }}>
|
||
좌측 행을 클릭하면 상세 표시
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const renderBody = () => {
|
||
switch (displayMode) {
|
||
case "split": return renderSplitMode();
|
||
case "table":
|
||
default: return renderBasicTable();
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div
|
||
style={containerStyle}
|
||
className={className}
|
||
onClick={(e) => { e.stopPropagation(); onClick?.(); }}
|
||
onDragStart={onDragStart}
|
||
onDragEnd={onDragEnd}
|
||
{...filterDOMProps(domProps)}
|
||
>
|
||
{renderToolbar()}
|
||
{renderBody()}
|
||
{renderFooter()}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// ─── 스타일 상수 ───
|
||
const thStyle: React.CSSProperties = {
|
||
padding: "8px 10px", fontSize: "11px", fontWeight: 700,
|
||
color: "hsl(var(--foreground))", textTransform: "uppercase",
|
||
letterSpacing: "0.03em", borderBottom: "1px solid hsl(var(--border))",
|
||
whiteSpace: "nowrap",
|
||
};
|
||
const tdStyle: React.CSSProperties = {
|
||
padding: "6px 10px", fontSize: "12px", color: "hsl(var(--foreground))",
|
||
};
|
||
const btnStyle: React.CSSProperties = {
|
||
padding: "2px 8px", fontSize: "11px",
|
||
border: "1px solid hsl(var(--border))", background: "hsl(var(--card))",
|
||
borderRadius: "4px", cursor: "pointer",
|
||
};
|
||
const toolbarStyle: React.CSSProperties = {
|
||
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||
padding: "6px 10px", borderBottom: "1px solid hsl(var(--border))",
|
||
background: "hsl(var(--muted))", flexShrink: 0,
|
||
};
|
||
const footerStyle: React.CSSProperties = {
|
||
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||
padding: "6px 10px", borderTop: "1px solid hsl(var(--border))",
|
||
background: "hsl(var(--muted))", fontSize: "11px",
|
||
color: "hsl(var(--muted-foreground))", flexShrink: 0,
|
||
};
|
||
|
||
export const TableWrapper: React.FC<TableComponentProps> = (props) => {
|
||
return <TableComponent {...props} />;
|
||
};
|