feat: 테이블 grouped/card 모드 본체 통합 (T3a)

5 viewMode 통합 두번째 단계 — TableComponent.switch 에 grouped/card/pivot
case 분기 추가. 별도 v2-* 컴포넌트 호출 X. table/views/* 분리.

GroupedView (신규, table/views/GroupedView.tsx)
- config.groupBy 기준으로 데이터를 그룹화해 펼침/접힘 단위로 렌더
- 그룹 헤더에 그룹 키 + 행 개수 표시. ChevronRight/Down 토글
- groupBy 미설정 시 안내 메시지

CardView (신규, table/views/CardView.tsx)
- config.cardsPerRow 으로 그리드, cardSpacing 으로 간격
- cardColumnMapping (titleColumn / subtitleColumn / descriptionColumn /
  imageColumn / displayColumns / actionColumns) 으로 데이터 → 카드 매핑
- cardStyle (showTitle/Subtitle/Description/Image, imagePosition,
  imageSize, showActions, showView/Edit/DeleteButton)

PivotView (placeholder, T3b 에서 통째 흡수 예정)
- v2-pivot-grid/PivotGridComponent (1963) + utils/pivotEngine.ts (700) +
  보조 타입 통째 흡수가 다음 단계
- 현재는 설정된 필드/행 수만 표시하는 placeholder

TableComponent.switch
- case "grouped" / "card" / "pivot" 신규 분기. case "split"/"table"/default 유지
- DOM filter 에 cardsPerRow/cardSpacing/cardStyle/cardColumnMapping/pivotFields 추가

빌드 OK. grouped/card 모드 사용자 선택 시 정상 렌더 (config 옵션 없으면 안내).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
DDD1542
2026-04-29 13:49:32 +09:00
parent 442f641305
commit a74dff4fa2
4 changed files with 510 additions and 2 deletions
@@ -10,6 +10,9 @@ import {
TableRowHeight,
} from "./types";
import { useTableData } from "./useTableData";
import { GroupedView } from "./views/GroupedView";
import { CardView } from "./views/CardView";
import { PivotView } from "./views/PivotView";
const VALID_MODES: TableDisplayMode[] = ["table", "split", "grouped", "pivot", "card"];
const ROW_HEIGHT_PRESETS: Record<TableRowHeight, string> = {
@@ -240,6 +243,8 @@ export const TableComponent: React.FC<TableComponentProps> = ({
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,
pivotFields: _67a,
cardsPerRow: _67b, cardSpacing: _67c, cardStyle: _67d, cardColumnMapping: _67e,
emptyMessage: _68, showToolbar: _69, showExcel: _70, showRefresh: _71,
disabled: _72, required: _73,
// Search ↔ Table 연동 props — DOM 에 흘리지 않음
@@ -469,9 +474,45 @@ export const TableComponent: React.FC<TableComponentProps> = ({
const renderBody = () => {
switch (displayMode) {
case "split": return renderSplitMode();
case "split":
return renderSplitMode();
case "grouped":
return (
<GroupedView
config={componentConfig}
columns={columns}
data={rows}
rowHeightPx={rowHeight}
isDesignMode={isDesignMode}
onRowClick={isDesignMode ? undefined : (row) => {
const idx = tableData.data.indexOf(row);
if (idx >= 0) handleRowClick(idx);
}}
/>
);
case "card":
return (
<CardView
config={componentConfig}
data={rows}
isDesignMode={isDesignMode}
onCardClick={isDesignMode ? undefined : (row) => {
const idx = tableData.data.indexOf(row);
if (idx >= 0) handleRowClick(idx);
}}
/>
);
case "pivot":
return (
<PivotView
config={componentConfig}
data={rows}
isDesignMode={isDesignMode}
/>
);
case "table":
default: return renderBasicTable();
default:
return renderBasicTable();
}
};
@@ -0,0 +1,228 @@
"use client";
import React from "react";
import { Eye, Pencil, Trash2 } from "lucide-react";
import type { TableConfig, TableCardStyleConfig } from "../types";
export interface CardViewProps {
config: TableConfig;
data: any[];
isDesignMode?: boolean;
onCardClick?: (row: any) => void;
onView?: (row: any) => void;
onEdit?: (row: any) => void;
onDelete?: (row: any) => void;
}
const DEFAULT_STYLE: Required<TableCardStyleConfig> = {
showTitle: true,
showSubtitle: true,
showDescription: true,
showImage: true,
maxDescriptionLength: 120,
imagePosition: "top",
imageSize: "medium",
showActions: false,
showViewButton: false,
showEditButton: false,
showDeleteButton: false,
};
const IMAGE_SIZE_PX: Record<NonNullable<TableCardStyleConfig["imageSize"]>, number> = {
small: 80,
medium: 140,
large: 200,
};
/**
* CardView — displayMode="card"
*
* 데이터 행을 카드 그리드로 렌더. config.cardColumnMapping 으로 데이터 컬럼을
* 카드 영역 (title/subtitle/description/image) 에 매핑.
*/
export function CardView({
config,
data,
isDesignMode = false,
onCardClick,
onView,
onEdit,
onDelete,
}: CardViewProps) {
const cardsPerRow = config.cardsPerRow ?? 3;
const cardSpacing = config.cardSpacing ?? 12;
const style: Required<TableCardStyleConfig> = {
...DEFAULT_STYLE,
...(config.cardStyle ?? {}),
};
const mapping = config.cardColumnMapping ?? {};
if (data.length === 0) {
return <div style={emptyStyle}>{config.emptyMessage || "데이터 없음"}</div>;
}
const gridStyle: React.CSSProperties = {
display: "grid",
gridTemplateColumns: `repeat(${cardsPerRow}, minmax(0, 1fr))`,
gap: cardSpacing,
padding: 12,
overflow: "auto",
flex: 1,
};
return (
<div style={gridStyle}>
{data.map((row, idx) => (
<CardItem
key={idx}
row={row}
mapping={mapping}
style={style}
onClick={onCardClick ? () => onCardClick(row) : undefined}
onView={style.showActions && style.showViewButton ? () => onView?.(row) : undefined}
onEdit={style.showActions && style.showEditButton ? () => onEdit?.(row) : undefined}
onDelete={style.showActions && style.showDeleteButton ? () => onDelete?.(row) : undefined}
isDesignMode={isDesignMode}
/>
))}
</div>
);
}
interface CardItemProps {
row: any;
mapping: NonNullable<TableConfig["cardColumnMapping"]>;
style: Required<TableCardStyleConfig>;
isDesignMode: boolean;
onClick?: () => void;
onView?: () => void;
onEdit?: () => void;
onDelete?: () => void;
}
function CardItem({ row, mapping, style, isDesignMode, onClick, onView, onEdit, onDelete }: CardItemProps) {
const title = mapping.titleColumn ? row?.[mapping.titleColumn] : undefined;
const subtitle = mapping.subtitleColumn ? row?.[mapping.subtitleColumn] : undefined;
const descriptionRaw = mapping.descriptionColumn ? row?.[mapping.descriptionColumn] : undefined;
const image = mapping.imageColumn ? row?.[mapping.imageColumn] : undefined;
const description =
typeof descriptionRaw === "string" && descriptionRaw.length > style.maxDescriptionLength
? `${descriptionRaw.slice(0, style.maxDescriptionLength)}`
: descriptionRaw;
const imagePx = IMAGE_SIZE_PX[style.imageSize];
const isHorizontal = style.imagePosition === "left" || style.imagePosition === "right";
const cardStyle: React.CSSProperties = {
border: "1px solid hsl(var(--border))",
borderRadius: 8,
background: "hsl(var(--card))",
overflow: "hidden",
cursor: onClick ? "pointer" : "default",
display: isHorizontal ? "flex" : "block",
flexDirection: style.imagePosition === "right" ? "row-reverse" : "row",
};
return (
<div style={cardStyle} onClick={onClick}>
{style.showImage && image && (
<div
style={{
width: isHorizontal ? imagePx : "100%",
height: imagePx,
background: "hsl(var(--muted))",
flexShrink: 0,
backgroundImage: `url(${image})`,
backgroundSize: "cover",
backgroundPosition: "center",
}}
/>
)}
<div style={{ padding: 12, flex: 1, minWidth: 0 }}>
{style.showTitle && title !== undefined && title !== null && title !== "" && (
<div style={{ fontSize: 13, fontWeight: 700, marginBottom: 4 }}>{String(title)}</div>
)}
{style.showSubtitle && subtitle !== undefined && subtitle !== null && subtitle !== "" && (
<div style={{ fontSize: 11, color: "hsl(var(--muted-foreground))", marginBottom: 6 }}>
{String(subtitle)}
</div>
)}
{style.showDescription && description !== undefined && description !== null && description !== "" && (
<div style={{ fontSize: 12, lineHeight: 1.5, marginBottom: style.showActions ? 8 : 0 }}>
{String(description)}
</div>
)}
{(mapping.displayColumns?.length ?? 0) > 0 && (
<div style={{ marginTop: 6, fontSize: 11, color: "hsl(var(--muted-foreground))" }}>
{mapping.displayColumns!.map((col) => (
<div key={col} style={{ display: "flex", gap: 6 }}>
<span style={{ fontWeight: 500 }}>{col}:</span>
<span>{row?.[col] !== undefined && row?.[col] !== null ? String(row[col]) : "-"}</span>
</div>
))}
</div>
)}
{style.showActions && (onView || onEdit || onDelete) && (
<div style={{ display: "flex", gap: 6, marginTop: 8 }}>
{onView && (
<ActionButton onClick={onView} icon={<Eye size={12} />} label="보기" />
)}
{onEdit && (
<ActionButton onClick={onEdit} icon={<Pencil size={12} />} label="편집" />
)}
{onDelete && (
<ActionButton onClick={onDelete} icon={<Trash2 size={12} />} label="삭제" />
)}
</div>
)}
{isDesignMode && Object.keys(mapping).length === 0 && (
<div style={{ fontSize: 10.5, color: "hsl(var(--muted-foreground))" }}>
[ ] .
</div>
)}
</div>
</div>
);
}
function ActionButton({
onClick,
icon,
label,
}: {
onClick: () => void;
icon: React.ReactNode;
label: string;
}) {
return (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onClick();
}}
style={{
display: "inline-flex",
alignItems: "center",
gap: 4,
padding: "3px 8px",
fontSize: 10.5,
background: "hsl(var(--muted))",
border: "1px solid hsl(var(--border))",
borderRadius: 4,
cursor: "pointer",
color: "hsl(var(--foreground))",
}}
>
{icon}
<span>{label}</span>
</button>
);
}
const emptyStyle: React.CSSProperties = {
padding: 24,
textAlign: "center",
fontSize: 12,
color: "hsl(var(--muted-foreground))",
};
@@ -0,0 +1,187 @@
"use client";
import React, { useMemo, useState } from "react";
import { ChevronRight, ChevronDown } from "lucide-react";
import type { TableConfig, TableColumn } from "../types";
export interface GroupedViewProps {
config: TableConfig;
columns: TableColumn[];
data: any[];
rowHeightPx?: string;
isDesignMode?: boolean;
onRowClick?: (row: any) => void;
}
/**
* GroupedView — displayMode="grouped"
*
* config.groupBy 컬럼 기준으로 데이터를 그룹화해 펼침/접힘 단위로 렌더.
* 그룹 헤더에 그룹 키 + 행 개수 표시. 클릭 시 펼침 토글.
*/
export function GroupedView({
config,
columns,
data,
rowHeightPx = "36px",
isDesignMode = false,
onRowClick,
}: GroupedViewProps) {
const groupBy = config.groupBy;
const groups = useMemo<Array<{ key: string; rows: any[] }>>(() => {
if (!groupBy) return [{ key: "(전체)", rows: data }];
const map = new Map<string, any[]>();
for (const row of data) {
const raw = row?.[groupBy];
const key = raw === null || raw === undefined || raw === "" ? "(빈 값)" : String(raw);
const list = map.get(key);
if (list) list.push(row);
else map.set(key, [row]);
}
return Array.from(map.entries()).map(([key, rows]) => ({ key, rows }));
}, [data, groupBy]);
const [collapsedKeys, setCollapsedKeys] = useState<Set<string>>(new Set());
const toggle = (key: string) => {
setCollapsedKeys((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
if (!groupBy) {
return (
<div style={emptyStyle}>
(config.groupBy)
</div>
);
}
if (data.length === 0) {
return <div style={emptyStyle}>{config.emptyMessage || "데이터 없음"}</div>;
}
return (
<div style={{ overflow: "auto", flex: 1 }}>
<table style={tableStyle}>
<thead>
<tr>
<th style={{ ...thStyle, width: 28 }}></th>
{columns.map((col) => (
<th
key={col.key}
style={{
...thStyle,
width: col.width,
textAlign: col.align || "left",
}}
>
{col.label || col.key}
</th>
))}
</tr>
</thead>
<tbody>
{groups.map(({ key, rows }) => {
const collapsed = collapsedKeys.has(key);
return (
<React.Fragment key={key}>
<tr style={groupHeaderRowStyle} onClick={() => toggle(key)}>
<td style={{ ...tdStyle, width: 28, cursor: "pointer" }}>
{collapsed ? (
<ChevronRight size={12} />
) : (
<ChevronDown size={12} />
)}
</td>
<td
style={{ ...tdStyle, fontWeight: 600 }}
colSpan={columns.length}
>
{key}{" "}
<span style={{ color: "hsl(var(--muted-foreground))", fontSize: 11 }}>
({rows.length})
</span>
</td>
</tr>
{!collapsed &&
rows.map((row, idx) => (
<tr
key={`${key}-${idx}`}
style={{ height: rowHeightPx, cursor: onRowClick ? "pointer" : "default" }}
onClick={() => onRowClick?.(row)}
>
<td style={tdStyle}></td>
{columns.map((col) => (
<td
key={col.key}
style={{ ...tdStyle, textAlign: col.align || "left" }}
>
{formatCell(row?.[col.key], col.format)}
</td>
))}
</tr>
))}
</React.Fragment>
);
})}
</tbody>
</table>
{isDesignMode && (
<div style={{ padding: "6px 10px", fontSize: 10.5, color: "hsl(var(--muted-foreground))" }}>
[ ] {groups.length}
</div>
)}
</div>
);
}
function formatCell(value: any, _format?: string): React.ReactNode {
if (value === null || value === undefined) return "-";
if (typeof value === "boolean") return value ? "✓" : "✗";
if (typeof value === "object") return JSON.stringify(value);
return String(value);
}
const tableStyle: React.CSSProperties = {
width: "100%",
borderCollapse: "collapse",
fontSize: 12,
};
const thStyle: React.CSSProperties = {
padding: "8px 10px",
fontSize: 11,
fontWeight: 700,
color: "hsl(var(--foreground))",
textTransform: "uppercase",
letterSpacing: "0.03em",
borderBottom: "1px solid hsl(var(--border))",
textAlign: "left",
whiteSpace: "nowrap",
background: "hsl(var(--muted))",
position: "sticky",
top: 0,
};
const tdStyle: React.CSSProperties = {
padding: "6px 10px",
fontSize: 12,
color: "hsl(var(--foreground))",
borderBottom: "1px solid hsl(var(--border) / 0.5)",
};
const groupHeaderRowStyle: React.CSSProperties = {
background: "hsl(var(--muted) / 0.5)",
cursor: "pointer",
};
const emptyStyle: React.CSSProperties = {
padding: 24,
textAlign: "center",
fontSize: 12,
color: "hsl(var(--muted-foreground))",
};
@@ -0,0 +1,52 @@
"use client";
import React from "react";
import type { TableConfig } from "../types";
export interface PivotViewProps {
config: TableConfig;
data: any[];
isDesignMode?: boolean;
}
/**
* PivotView — displayMode="pivot"
*
* ⚠️ 통합 대기 — 다음 단계 (T3b) 에서 v2-pivot-grid 의 본체(1963줄) +
* utils/pivotEngine.ts 등 통째 흡수. 현재는 placeholder.
*/
export function PivotView({ config, data, isDesignMode = false }: PivotViewProps) {
const fieldsCount = config.pivotFields?.length ?? 0;
const rowsCount = config.pivotRows?.length ?? 0;
const colsCount = config.pivotColumns?.length ?? 0;
const valsCount = config.pivotValues?.length ?? 0;
return (
<div
style={{
flex: 1,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
padding: 24,
color: "hsl(var(--muted-foreground))",
fontSize: 12,
gap: 6,
textAlign: "center",
}}
>
<div style={{ fontWeight: 600, fontSize: 13 }}> ( )</div>
<div>
필드: pivotFields {fieldsCount} · pivotRows {rowsCount} · pivotColumns {colsCount} ·
pivotValues {valsCount}
</div>
<div> : {data.length}</div>
{isDesignMode && (
<div style={{ fontSize: 10.5, marginTop: 8 }}>
[ ] v2-pivot-grid pivotEngine + .
</div>
)}
</div>
);
}