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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user