Files
invyone/frontend/lib/registry/components/table/TableComponent.tsx
T
DDD1542 3eda684787
Build & Deploy to K8s / build-and-deploy (push) Successful in 4m22s
사용자 대시보드 기능강화 및 인비온 스튜디오 메뉴관리 자잘한수정
2026-04-22 18:27:06 +09:00

452 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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} />;
};