"use client"; /** * InvTableConfigPanel — 통합 "테이블" (id: table) cp 톤 설정 패널 * * 흐름: * ① 테이블 연결 — 테이블 선택 + DB 컬럼 자동 로드 * ② 표시 모드 — CPVisualGrid 5칸 (기본/분할/그룹/피벗/카드 시각 미리보기) * ③ 행 선택 + 높이 — CPSegment * ④ 모드별 설정 — split 비율 / grouped 컬럼 (조건부) * ⑤ 컬럼 편집 — dense list (한 줄 = 라벨 + key + 너비 + align + 정렬) * ▾ 동작 옵션 — 헤더/푸터/체크박스/줄무늬/호버/툴바 (FeatureChipGrid) * * Reference: notes/gbpark/2026-04-28-cp-panel-standard.md */ import React, { useCallback, useEffect, useMemo, useState } from "react"; import { Table2, Columns2, List, Grid3x3, LayoutGrid, Plus, AlignLeft, AlignCenter, AlignRight, } from "lucide-react"; import { CPSection, CPRow, CPGroup, CPText, CPSelect, CPSegment, CPNumber, CPSwitch, CPVisualGrid, FeatureChipGrid, Hint, } from "@/components/v2/config-panels/_shared/cp"; import { useDbTables } from "../common/useDbTables"; import { TableConnectSection, AutoLoadButton } from "../common/TableConnectSection"; import { RowNumberBadge, RowExpandChevron, RowDeleteBtn, } from "../common/row-helpers"; import type { TableConfig, TableColumn } from "./types"; export interface InvTableConfigPanelProps { config?: TableConfig; onChange?: (config: TableConfig) => void; selectedComponent?: { id: string; config?: TableConfig; [k: string]: any }; tables?: any[]; tableColumns?: any[]; screenTableName?: string; onTableChange?: (tableName: string) => void; } export const InvTableConfigPanel: React.FC = ({ config, onChange, selectedComponent, tables, tableColumns, screenTableName, onTableChange, }) => { const current: TableConfig = (config as TableConfig) || (selectedComponent?.config as TableConfig) || {}; const patch = useCallback( (p: Partial) => onChange?.({ ...current, ...p }), [current, onChange], ); const columns: TableColumn[] = current.columns ?? []; const connectedTable = (current as any).selectedTable || screenTableName; const { options: tableOptions } = useDbTables({ fallback: tables }); // ── 연결된 테이블의 컬럼 로드 (자동 로드 button 용) ── const [connectedTableColumns, setConnectedTableColumns] = useState([]); const [loadingConnectedColumns, setLoadingConnectedColumns] = useState(false); useEffect(() => { let cancelled = false; if (!connectedTable) { setConnectedTableColumns([]); setLoadingConnectedColumns(false); return () => { cancelled = true; }; } if ( connectedTable === screenTableName && Array.isArray(tableColumns) && tableColumns.length > 0 ) { setConnectedTableColumns(tableColumns); setLoadingConnectedColumns(false); return () => { cancelled = true; }; } setLoadingConnectedColumns(true); (async () => { try { const { tableTypeApi } = await import("@/lib/api/screen"); const cols = await tableTypeApi.getColumns(connectedTable, true); if (!cancelled) setConnectedTableColumns(Array.isArray(cols) ? cols : []); } catch { if (!cancelled) setConnectedTableColumns([]); } finally { if (!cancelled) setLoadingConnectedColumns(false); } })(); return () => { cancelled = true; }; }, [connectedTable, screenTableName, tableColumns]); const effectiveTableColumns = useMemo(() => { if ( connectedTable === screenTableName && Array.isArray(tableColumns) && tableColumns.length > 0 ) { return tableColumns; } return connectedTableColumns; }, [connectedTable, screenTableName, tableColumns, connectedTableColumns]); const autoLoadColumns = useCallback(() => { if (!effectiveTableColumns?.length) return; const newCols: TableColumn[] = effectiveTableColumns.map((col: any) => ({ key: col.columnName || col.column_name, label: col.columnLabel || col.column_label || col.displayName || col.columnName || col.column_name, width: undefined, align: "left" as const, sortable: true, visible: true, })); patch({ columns: newCols }); }, [effectiveTableColumns, patch]); const updateColumn = (idx: number, col: Partial) => { const next = columns.map((c, i) => (i === idx ? { ...c, ...col } : c)); patch({ columns: next }); }; const addColumn = () => { patch({ columns: [ ...columns, { key: `col${columns.length + 1}`, label: `컬럼 ${columns.length + 1}` }, ], }); }; const removeColumn = (idx: number) => { patch({ columns: columns.filter((_, i) => i !== idx) }); }; const displayMode = current.displayMode || "table"; return (
{/* ── ① 테이블 연결 ─────────────────────────── */} { onTableChange?.(v); patch({ selectedTable: v, columns: [] } as any); }} options={tableOptions} desc="DB 테이블 매핑" > {connectedTable && ( {loadingConnectedColumns ? "컬럼 정보를 불러오는 중..." : effectiveTableColumns.length > 0 ? `컬럼 ${effectiveTableColumns.length}개 준비됨` : "불러온 컬럼 정보가 없습니다"} )} {connectedTable && effectiveTableColumns.length > 0 && ( )} {/* ── ② 표시 모드 ─────────────────────────── */} patch({ displayMode: v as TableConfig["displayMode"] })} options={[ { value: "table", label: "기본", preview: , desc: "일반 테이블" }, { value: "split", label: "분할", preview: , desc: "좌우 분할" }, { value: "grouped", label: "그룹", preview: , desc: "그룹핑" }, { value: "pivot", label: "피벗", preview: , desc: "피벗 그리드" }, { value: "card", label: "카드", preview: , desc: "카드 리스트" }, ]} /> {/* ── ③ 행 선택 + 높이 ─────────────────────────── */} patch({ selectionMode: v as TableConfig["selectionMode"] })} options={[ { value: "none", label: "없음" }, { value: "single", label: "단일" }, { value: "multiple", label: "복수" }, ]} /> patch({ rowHeight: v as TableConfig["rowHeight"] })} options={[ { value: "compact", label: "좁게" }, { value: "normal", label: "기본" }, { value: "relaxed", label: "넓게" }, ]} /> {/* ── ④ 모드별 설정 (조건부) ─────────────────────────── */} {displayMode === "split" && ( patch({ splitRatio: v ?? 0.5 })} min={0.1} max={0.9} step={0.05} /> )} {displayMode === "grouped" && ( patch({ groupBy: v || undefined })} searchable={false} > {columns.map((c) => ( ))} {columns.length === 0 && ( 컬럼을 먼저 자동 로드 또는 추가하세요. )} )} {displayMode === "pivot" && ( {columns.length === 0 ? ( 컬럼을 먼저 자동 로드 또는 추가하세요. ) : ( <> 각 컬럼의 영역을 지정하면 피벗 필드가 생성됩니다. data 영역만 집계 함수가 필요합니다. {columns.map((col, idx) => { const fields = current.pivotFields ?? []; const fieldIdx = fields.findIndex((f) => f.field === col.key); const field = fieldIdx >= 0 ? fields[fieldIdx] : undefined; const area = field?.area ?? "none"; const summaryType = field?.summaryType ?? "sum"; const updateField = (next: Partial[number]> | "remove") => { const list = [...fields]; if (next === "remove") { if (fieldIdx >= 0) list.splice(fieldIdx, 1); } else if (fieldIdx >= 0) { list[fieldIdx] = { ...list[fieldIdx], ...next }; } else { list.push({ field: col.key, caption: col.label || col.key, area: "row", ...next, }); } patch({ pivotFields: list }); }; return (
{ if (v === "none") updateField("remove"); else updateField({ area: v as any }); }} searchable={false} > {area === "data" && ( updateField({ summaryType: v as any })} searchable={false} > )}
); })} )}
)} {displayMode === "card" && ( patch({ cardsPerRow: v ?? 3 })} min={1} max={10} /> patch({ cardSpacing: v ?? 12 })} min={0} max={64} /> patch({ cardStyle: { ...current.cardStyle, showTitle: v } })} /> patch({ cardStyle: { ...current.cardStyle, showSubtitle: v } })} /> patch({ cardStyle: { ...current.cardStyle, showDescription: v } })} /> patch({ cardStyle: { ...current.cardStyle, showImage: v } })} /> {(current.cardStyle?.showImage ?? true) && ( <> patch({ cardStyle: { ...current.cardStyle, imagePosition: v as any } })} options={[ { value: "top", label: "상단" }, { value: "left", label: "좌측" }, { value: "right", label: "우측" }, ]} /> patch({ cardStyle: { ...current.cardStyle, imageSize: v as any } })} options={[ { value: "small", label: "작게" }, { value: "medium", label: "보통" }, { value: "large", label: "크게" }, ]} /> )} {columns.length === 0 ? ( 컬럼을 먼저 자동 로드 또는 추가하세요. ) : ( <> patch({ cardColumnMapping: { ...current.cardColumnMapping, titleColumn: v || undefined, }, }) } > {columns.map((c) => ( ))} patch({ cardColumnMapping: { ...current.cardColumnMapping, subtitleColumn: v || undefined, }, }) } > {columns.map((c) => ( ))} patch({ cardColumnMapping: { ...current.cardColumnMapping, descriptionColumn: v || undefined, }, }) } > {columns.map((c) => ( ))} patch({ cardColumnMapping: { ...current.cardColumnMapping, imageColumn: v || undefined, }, }) } > {columns.map((c) => ( ))} )} patch({ cardStyle: { ...current.cardStyle, showActions: v } })} /> {current.cardStyle?.showActions && ( <> patch({ cardStyle: { ...current.cardStyle, showViewButton: v } }) } /> patch({ cardStyle: { ...current.cardStyle, showEditButton: v } }) } /> patch({ cardStyle: { ...current.cardStyle, showDeleteButton: v } }) } /> )} )} {/* ── ⑤ 컬럼 ─────────────────────────── */}
{columns.length === 0 ? ( {connectedTable ? loadingConnectedColumns ? "컬럼 정보를 불러오는 중입니다." : effectiveTableColumns.length > 0 ? "위 [자동 로드] 버튼으로 한 번에 가져올 수 있습니다." : "연결된 테이블에서 컬럼 정보를 찾지 못했습니다." : "테이블을 연결하면 자동 로드할 수 있습니다."} ) : (
{columns.map((col, idx) => ( updateColumn(idx, p)} onRemove={() => removeColumn(idx)} /> ))}
)}
{/* ── ▾ 동작 옵션 ─────────────────────────── */} patch({ [k]: v } as Partial)} />
); }; InvTableConfigPanel.displayName = "InvTableConfigPanel"; // ─────────────────────────────────────────────────────── // ColumnEditRow — 컬럼 한 줄 편집 (dense) // ─────────────────────────────────────────────────────── function ColumnEditRow({ index, col, isLast, onChange, onRemove, }: { index: number; col: TableColumn; isLast: boolean; onChange: (p: Partial) => void; onRemove: () => void; }) { const [hover, setHover] = useState(false); const [expanded, setExpanded] = useState(false); return (
setHover(true)} onMouseLeave={() => setHover(false)} style={{ borderBottom: isLast ? "none" : "1px solid var(--cp-border-subtle)", background: hover ? "var(--cp-surface-hover, var(--cp-surface))" : "transparent", transition: "background .12s ease", fontFamily: "var(--v5-font-sans)", }} > {/* 한 줄: # + 라벨 + key + 펼침 화살표 + 삭제 */}
onChange({ label: e.target.value })} placeholder="라벨" style={inputStyle()} /> onChange({ key: e.target.value })} placeholder="컬럼 key" style={inputStyle({ mono: true })} /> setExpanded((x) => !x)} />
{/* 펼친 옵션: 너비 / 정렬 / 정렬 가능 */} {expanded && (
onChange({ width: v })} placeholder="auto" min={0} /> onChange({ align: v as TableColumn["align"] })} options={[ { value: "left", label: }, { value: "center", label: }, { value: "right", label: }, ]} />
onChange({ sortable: v })} />
)}
); } function inputStyle({ mono = false }: { mono?: boolean } = {}): React.CSSProperties { return { height: 22, padding: "0 6px", fontSize: 11, fontFamily: mono ? "var(--v5-font-mono)" : "var(--v5-font-sans)", background: "var(--cp-surface)", border: "1px solid var(--cp-border)", borderRadius: 3, color: "var(--cp-text)", outline: "none", minWidth: 0, }; } export default InvTableConfigPanel;