1aa48cc0bb
## 디자인 개편 - 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>
563 lines
16 KiB
TypeScript
563 lines
16 KiB
TypeScript
"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} />;
|
||
};
|