From a74dff4fa21ac12609b7507cce546295feb67a91 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Wed, 29 Apr 2026 13:49:32 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=85=8C=EC=9D=B4=EB=B8=94=20grouped/c?= =?UTF-8?q?ard=20=EB=AA=A8=EB=93=9C=20=EB=B3=B8=EC=B2=B4=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9=20(T3a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../components/table/TableComponent.tsx | 45 +++- .../components/table/views/CardView.tsx | 228 ++++++++++++++++++ .../components/table/views/GroupedView.tsx | 187 ++++++++++++++ .../components/table/views/PivotView.tsx | 52 ++++ 4 files changed, 510 insertions(+), 2 deletions(-) create mode 100644 frontend/lib/registry/components/table/views/CardView.tsx create mode 100644 frontend/lib/registry/components/table/views/GroupedView.tsx create mode 100644 frontend/lib/registry/components/table/views/PivotView.tsx diff --git a/frontend/lib/registry/components/table/TableComponent.tsx b/frontend/lib/registry/components/table/TableComponent.tsx index 2618e0f6..613deb60 100644 --- a/frontend/lib/registry/components/table/TableComponent.tsx +++ b/frontend/lib/registry/components/table/TableComponent.tsx @@ -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 = { @@ -240,6 +243,8 @@ export const TableComponent: React.FC = ({ 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 = ({ const renderBody = () => { switch (displayMode) { - case "split": return renderSplitMode(); + case "split": + return renderSplitMode(); + case "grouped": + return ( + { + const idx = tableData.data.indexOf(row); + if (idx >= 0) handleRowClick(idx); + }} + /> + ); + case "card": + return ( + { + const idx = tableData.data.indexOf(row); + if (idx >= 0) handleRowClick(idx); + }} + /> + ); + case "pivot": + return ( + + ); case "table": - default: return renderBasicTable(); + default: + return renderBasicTable(); } }; diff --git a/frontend/lib/registry/components/table/views/CardView.tsx b/frontend/lib/registry/components/table/views/CardView.tsx new file mode 100644 index 00000000..6cfc4cb1 --- /dev/null +++ b/frontend/lib/registry/components/table/views/CardView.tsx @@ -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 = { + 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, 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 = { + ...DEFAULT_STYLE, + ...(config.cardStyle ?? {}), + }; + const mapping = config.cardColumnMapping ?? {}; + + if (data.length === 0) { + return
{config.emptyMessage || "데이터 없음"}
; + } + + const gridStyle: React.CSSProperties = { + display: "grid", + gridTemplateColumns: `repeat(${cardsPerRow}, minmax(0, 1fr))`, + gap: cardSpacing, + padding: 12, + overflow: "auto", + flex: 1, + }; + + return ( +
+ {data.map((row, idx) => ( + 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} + /> + ))} +
+ ); +} + +interface CardItemProps { + row: any; + mapping: NonNullable; + style: Required; + 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 ( +
+ {style.showImage && image && ( +
+ )} +
+ {style.showTitle && title !== undefined && title !== null && title !== "" && ( +
{String(title)}
+ )} + {style.showSubtitle && subtitle !== undefined && subtitle !== null && subtitle !== "" && ( +
+ {String(subtitle)} +
+ )} + {style.showDescription && description !== undefined && description !== null && description !== "" && ( +
+ {String(description)} +
+ )} + {(mapping.displayColumns?.length ?? 0) > 0 && ( +
+ {mapping.displayColumns!.map((col) => ( +
+ {col}: + {row?.[col] !== undefined && row?.[col] !== null ? String(row[col]) : "-"} +
+ ))} +
+ )} + {style.showActions && (onView || onEdit || onDelete) && ( +
+ {onView && ( + } label="보기" /> + )} + {onEdit && ( + } label="편집" /> + )} + {onDelete && ( + } label="삭제" /> + )} +
+ )} + {isDesignMode && Object.keys(mapping).length === 0 && ( +
+ [디자인 모드] 컬럼 매핑이 비어있어 빈 카드만 표시됩니다. +
+ )} +
+
+ ); +} + +function ActionButton({ + onClick, + icon, + label, +}: { + onClick: () => void; + icon: React.ReactNode; + label: string; +}) { + return ( + + ); +} + +const emptyStyle: React.CSSProperties = { + padding: 24, + textAlign: "center", + fontSize: 12, + color: "hsl(var(--muted-foreground))", +}; diff --git a/frontend/lib/registry/components/table/views/GroupedView.tsx b/frontend/lib/registry/components/table/views/GroupedView.tsx new file mode 100644 index 00000000..05990d9b --- /dev/null +++ b/frontend/lib/registry/components/table/views/GroupedView.tsx @@ -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>(() => { + if (!groupBy) return [{ key: "(전체)", rows: data }]; + const map = new Map(); + 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>(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 ( +
+ 그룹화할 컬럼이 설정되지 않았습니다 (config.groupBy) +
+ ); + } + + if (data.length === 0) { + return
{config.emptyMessage || "데이터 없음"}
; + } + + return ( +
+ + + + + {columns.map((col) => ( + + ))} + + + + {groups.map(({ key, rows }) => { + const collapsed = collapsedKeys.has(key); + return ( + + toggle(key)}> + + + + {!collapsed && + rows.map((row, idx) => ( + onRowClick?.(row)} + > + + {columns.map((col) => ( + + ))} + + ))} + + ); + })} + +
+ {col.label || col.key} +
+ {collapsed ? ( + + ) : ( + + )} + + {key}{" "} + + ({rows.length}건) + +
+ {formatCell(row?.[col.key], col.format)} +
+ {isDesignMode && ( +
+ [디자인 모드] {groups.length}개 그룹 +
+ )} +
+ ); +} + +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))", +}; diff --git a/frontend/lib/registry/components/table/views/PivotView.tsx b/frontend/lib/registry/components/table/views/PivotView.tsx new file mode 100644 index 00000000..d7d759a9 --- /dev/null +++ b/frontend/lib/registry/components/table/views/PivotView.tsx @@ -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 ( +
+
피벗 (구현 대기 중)
+
+ 설정된 필드: pivotFields {fieldsCount} · pivotRows {rowsCount} · pivotColumns {colsCount} · + pivotValues {valsCount} +
+
데이터 행: {data.length}
+ {isDesignMode && ( +
+ [디자인 모드] 다음 단계에서 v2-pivot-grid 의 pivotEngine + 본체를 흡수해 실제 피벗 그리드를 렌더합니다. +
+ )} +
+ ); +}