Files
invyone/frontend/lib/registry/components/table/TableComponent.tsx
T
gbpark 1aa48cc0bb INVYONE 화면 디자이너 개편 + 컴포넌트 86→8 통합
## 디자인 개편
- IDE 톤 CSS 오버라이드 (builder-ide.css)
- 컴팩트화 (폰트/간격/패딩 축소)
- INVYONE STUDIO 로고 추가 (SlimToolbar)
- 좌측 수평 탭 → 수직 아코디언 (details/summary)
- 우측 속성 패널 신설 (V2PropertiesPanel 완전 이주)
- 다크모드 지원 (7개 통합 컴포넌트 inline hex → CSS 변수)

## 기반 시스템
- ScreenDefinition.fields/connections 타입 확장
- ComponentDefinition.dataPorts 타입 확장
- FieldConfig adapters (fieldsToColumns/Search/Form)
- DataPortBus + setupConnections runtime
- FieldsPanel (화면 수준 필드 관리 패널)

## 컴포넌트 통합 (Phase A~C)
- divider (3→1): 가로/세로 + 텍스트 구분선
- title (2→1): h1~h6/body/caption variant
- button (3→1): 6 variant × 13 actionType
- search (3→1): inline/stacked 검색 필터
- input (20+→1): FieldConfig.type 10종 내부 분기
- stats (6→1): card/chip/bigNumber 3종 스타일
- table (9→1): table/split/grouped/pivot/card 5종 displayMode
- container (11→1): tabs/section/accordion/repeater/conditional 5종

## 버그 수정 (기존 VEX 코드)
- 드래그 드롭 불가 (defaultSize camelCase 불일치)
- 설정 변경 미반영 (componentConfig vs component_config)
- ConfigPanel 미인식 (config_panel vs configPanel)
- v2- 자동 매핑 함정 (INVYONE_UNIFIED_IDS 화이트리스트)
- LayerManagerPanel 무한 API 호출 (useEffect deps)
- Button size 이름 충돌 (visual size 객체 vs config string)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 20:37:23 +09:00

563 lines
16 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 from "react";
import { ComponentRendererProps } from "@/types/component";
import {
TableConfig,
TableDisplayMode,
TableColumn,
TableRowHeight,
} from "./types";
/**
* Table — 통합 데이터 테이블 컴포넌트
*
* displayMode 로 기본/분할/그룹/피벗/카드 모드 분기. Phase C-1 최소 구현으로
* 각 모드는 **스켈레톤 렌더** 만. 실제 데이터 조회/정렬/페이지네이션 로직은
* Phase F 에서 DB 연결 + FieldConfig 기반 자동화로 확장.
*
* 관련: notes/gbpark/2026-04-11-component-unification-plan.md §3.1
*/
const VALID_MODES: TableDisplayMode[] = [
"table",
"split",
"grouped",
"pivot",
"card",
];
const ROW_HEIGHT_PRESETS: Record<TableRowHeight, string> = {
compact: "28px",
normal: "36px",
relaxed: "44px",
};
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";
// ─── columns 결정 (fields 우선, 없으면 columns, 없으면 placeholder) ───
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 [
{ key: "col1", label: "컬럼 1" },
{ key: "col2", label: "컬럼 2" },
{ key: "col3", label: "컬럼 3" },
];
})();
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 ?? "데이터가 없습니다";
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 handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
onClick?.();
};
/* 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,
// ★ TableConfig 필드 제외
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,
...domProps
} = props as any;
/* eslint-enable @typescript-eslint/no-unused-vars */
const renderToolbar = () =>
showToolbar && (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "6px 10px",
borderBottom: "1px solid hsl(var(--border))",
background: "hsl(var(--muted))",
flexShrink: 0,
}}
>
<span style={{ fontSize: "11px", color: "hsl(var(--muted-foreground))", fontWeight: 600 }}>
{displayMode.toUpperCase()} · {columns.length}
</span>
<div style={{ display: "flex", gap: "4px" }}>
{componentConfig.showRefresh && (
<button type="button" style={toolbarBtnStyle} disabled={isDesignMode}>
</button>
)}
{componentConfig.showExcel && (
<button type="button" style={toolbarBtnStyle} disabled={isDesignMode}>
📊
</button>
)}
</div>
</div>
);
const renderBasicTable = () => (
<div style={{ flex: 1, minHeight: 0, overflow: "auto" }}>
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: "12px" }}>
{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",
}}
>
{col.label}
{col.sortable && (
<span style={{ marginLeft: "4px", color: "hsl(var(--muted-foreground))", fontSize: "10px" }}>
</span>
)}
</th>
))}
</tr>
</thead>
)}
<tbody>
{[0, 1, 2].map((rowIdx) => (
<tr
key={rowIdx}
style={{
height: rowHeight,
background: striped && rowIdx % 2 === 1 ? "hsl(var(--muted))" : "transparent",
borderBottom: "1px solid hsl(var(--muted))",
cursor: hoverable ? "pointer" : "default",
}}
>
{showCheckbox && (
<td style={{ ...tdStyle, textAlign: "center" }}>
<input type="checkbox" disabled={isDesignMode} />
</td>
)}
{columns.map((col) => (
<td
key={col.key}
style={{ ...tdStyle, textAlign: col.align ?? "left" }}
>
<span style={{ color: "hsl(var(--muted-foreground))" }}>...</span>
</td>
))}
</tr>
))}
{columns.length === 0 && (
<tr>
<td
colSpan={1}
style={{
padding: "24px",
textAlign: "center",
color: "hsl(var(--muted-foreground))",
fontSize: "11px",
}}
>
{emptyMessage}
</td>
</tr>
)}
</tbody>
</table>
</div>
);
const renderSplitMode = () => {
const ratio = componentConfig.splitRatio ?? 0.5;
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: "12px",
background: "hsl(var(--muted))",
minWidth: 0,
}}
>
<div
style={{
fontSize: "11px",
color: "hsl(var(--muted-foreground))",
fontWeight: 600,
marginBottom: "8px",
}}
>
</div>
<div
style={{
border: "1px dashed hsl(var(--border))",
borderRadius: "4px",
padding: "20px",
textAlign: "center",
color: "hsl(var(--muted-foreground))",
fontSize: "11px",
}}
>
<div style={{ fontSize: "10px", marginTop: "4px" }}>
( )
</div>
</div>
</div>
</div>
);
};
const renderGroupedMode = () => {
const groupBy = componentConfig.groupBy;
return (
<div style={{ flex: 1, minHeight: 0, overflow: "auto" }}>
{["그룹 A", "그룹 B"].map((groupLabel) => (
<div key={groupLabel}>
<div
style={{
padding: "6px 10px",
background: "hsl(var(--accent))",
borderBottom: "1px solid hsl(var(--accent))",
fontSize: "11px",
fontWeight: 700,
color: "hsl(var(--primary))",
}}
>
{groupBy ? `${groupBy} = ${groupLabel}` : groupLabel}
</div>
<div style={{ padding: "0 16px" }}>{renderBasicTable()}</div>
</div>
))}
</div>
);
};
const renderPivotMode = () => (
<div
style={{
flex: 1,
minHeight: 0,
padding: "16px",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "hsl(var(--muted-foreground))",
fontSize: "12px",
background: "hsl(var(--muted))",
textAlign: "center",
}}
>
<div>
<div style={{ fontSize: "24px", marginBottom: "8px" }}>📊</div>
<div style={{ fontSize: "10px", marginTop: "4px" }}>
(Phase F placeholder)
</div>
</div>
</div>
);
const renderCardMode = () => (
<div
style={{
flex: 1,
minHeight: 0,
overflow: "auto",
padding: "8px",
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))",
gap: "8px",
}}
>
{[0, 1, 2].map((i) => (
<div
key={i}
style={{
border: "1px solid hsl(var(--border))",
borderRadius: "6px",
padding: "10px",
background: "hsl(var(--card))",
}}
>
{columns.map((col) => (
<div key={col.key} style={{ fontSize: "11px", marginBottom: "4px" }}>
<span style={{ color: "hsl(var(--muted-foreground))", marginRight: "4px" }}>{col.label}:</span>
<span style={{ color: "hsl(var(--muted-foreground))" }}>...</span>
</div>
))}
</div>
))}
</div>
);
const renderBody = () => {
switch (displayMode) {
case "split":
return renderSplitMode();
case "grouped":
return renderGroupedMode();
case "pivot":
return renderPivotMode();
case "card":
return renderCardMode();
case "table":
default:
return renderBasicTable();
}
};
return (
<div
style={containerStyle}
className={className}
onClick={handleClick}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
{...domProps}
>
{renderToolbar()}
{renderBody()}
{showFooter && componentConfig.pagination?.enabled !== false && (
<div
style={{
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,
}}
>
<span> 0</span>
<div style={{ display: "flex", gap: "4px" }}>
<button type="button" style={toolbarBtnStyle} disabled>
</button>
<span style={{ padding: "2px 8px" }}>1 / 1</span>
<button type="button" style={toolbarBtnStyle} disabled>
</button>
</div>
</div>
)}
</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 toolbarBtnStyle: React.CSSProperties = {
padding: "2px 8px",
fontSize: "11px",
border: "1px solid hsl(var(--border))",
background: "hsl(var(--card))",
borderRadius: "4px",
cursor: "pointer",
};
export const TableWrapper: React.FC<TableComponentProps> = (props) => {
return <TableComponent {...props} />;
};