Files
invyone/frontend/lib/registry/components/table/InvTableConfigPanel.tsx
T
DDD1542 a8ded6455d refactor: ConfigPanel Inv 네이밍 통합 + legacy 패널 분리 + input cp 마이그
11 패널 일괄 Inv* prefix 통일:
- 통합 (lib/registry/components/X/): button / container / divider / search /
  stats / table / title / input → Inv*ConfigPanel
- frontend/components/v2/config-panels/V2FieldConfigPanel → InvFieldConfigPanel
- 옛 v2-* hidden 호환 → InvLegacy{Divider,Text,Button}ConfigPanel

input 통합 컴포넌트 cp 톤 신규 작성 (InvInputConfigPanel):
- 277줄 옛 디자인 → CPVisualGrid 10칸 type 카드 + 타입별 옵션 + FeatureChipGrid

getComponentConfigPanel.tsx 버그 수정 (Codex 검토):
- "stats" key 중복 제거 (옛 StatsCardConfigPanel 이 통합 stats 덮던 silent bug)
- ALIAS 에서 v2-button-primary/v2-divider-line/v2-text-display 제외
  (옵션 B 일관성 — 옛 hidden 컴포넌트는 InvLegacy 패널 사용)
- MAP 의 해당 키를 InvLegacy* 로 직접 매핑

호출처 일괄 갱신:
- 각 통합 컴포넌트의 index.ts 7개 (import / config_panel / re-export)
- v2-input/v2-select/v2-divider-line/v2-text-display/v2-button-primary
  의 index.ts (config_panel 매핑)
- V2PropertiesPanel.tsx 의 require pattern (v2-input/v2-select)

검증: tsc 우리 영역 0건 / V2FieldConfigPanel 잔재 0건 / 기존 path 잔재 0건

다음 세션: useDbTables hook 추출 + 잔여 V2* cp 마이그 + dead code 정리

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:57:57 +09:00

656 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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,
Wand2,
AlignLeft,
AlignCenter,
AlignRight,
} from "lucide-react";
import {
CPSection,
CPRow,
CPGroup,
CPText,
CPSelect,
CPSegment,
CPNumber,
CPSwitch,
CPVisualGrid,
FeatureChipGrid,
Hint,
InlineLoader,
} from "@/components/v2/config-panels/_shared/cp";
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<InvTableConfigPanelProps> = ({
config,
onChange,
selectedComponent,
tables,
tableColumns,
screenTableName,
onTableChange,
}) => {
const current: TableConfig =
(config as TableConfig) || (selectedComponent?.config as TableConfig) || {};
const patch = useCallback(
(p: Partial<TableConfig>) => onChange?.({ ...current, ...p }),
[current, onChange],
);
const columns: TableColumn[] = current.columns ?? [];
const connectedTable = (current as any).selectedTable || screenTableName;
// ── 전체 DB 테이블 직접 로드 ──
const [allDbTables, setAllDbTables] = useState<any[]>([]);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const { tableManagementApi } = await import("@/lib/api/tableManagement");
const res = await tableManagementApi.getTableList();
if (!cancelled && res.success && res.data) setAllDbTables(res.data);
} catch {
/* ignore */
}
})();
return () => {
cancelled = true;
};
}, []);
const tableOptions = useMemo(() => {
const src = allDbTables.length > 0 ? allDbTables : tables || [];
return src.map((t: any) => ({
value: t.tableName || t.table_name,
label:
t.display_name || t.tableLabel || t.table_label || t.tableName || t.table_name,
}));
}, [allDbTables, tables]);
// ── 연결된 테이블의 컬럼 로드 (자동 로드 button 용) ──
const [connectedTableColumns, setConnectedTableColumns] = useState<any[]>([]);
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<TableColumn>) => {
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 (
<div style={{ fontFamily: "var(--v5-font-sans)", color: "var(--cp-text)", padding: "0 12px" }}>
{/* ── ① 테이블 연결 ─────────────────────────── */}
<CPSection title="① 테이블 연결" desc="DB 테이블 매핑">
<CPRow label="테이블">
<CPSelect
value={connectedTable || ""}
onChange={(v) => {
onTableChange?.(v);
patch({ selectedTable: v, columns: [] } as any);
}}
>
<option value="">...</option>
{tableOptions.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</CPSelect>
</CPRow>
{connectedTable && (
<Hint>
{loadingConnectedColumns
? "컬럼 정보를 불러오는 중..."
: effectiveTableColumns.length > 0
? `컬럼 ${effectiveTableColumns.length}개 준비됨`
: "불러온 컬럼 정보가 없습니다"}
</Hint>
)}
{connectedTable && effectiveTableColumns.length > 0 && (
<div style={{ marginTop: 6 }}>
<button
type="button"
onClick={autoLoadColumns}
style={{
width: "100%",
padding: "6px 10px",
fontSize: 10.5,
background: "rgba(var(--v5-primary-rgb), 0.08)",
border: "1px solid rgba(var(--v5-primary-rgb), 0.32)",
borderRadius: 4,
cursor: "pointer",
color: "var(--v5-primary, #6c5ce7)",
fontFamily: "var(--v5-font-sans)",
fontWeight: 600,
letterSpacing: "-0.005em",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
gap: 6,
transition: "background .14s ease, border-color .14s ease",
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLButtonElement).style.background =
"rgba(var(--v5-primary-rgb), 0.14)";
(e.currentTarget as HTMLButtonElement).style.borderColor =
"rgba(var(--v5-primary-rgb), 0.5)";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLButtonElement).style.background =
"rgba(var(--v5-primary-rgb), 0.08)";
(e.currentTarget as HTMLButtonElement).style.borderColor =
"rgba(var(--v5-primary-rgb), 0.32)";
}}
>
<Wand2 size={11} />
DB ({effectiveTableColumns.length})
</button>
</div>
)}
</CPSection>
{/* ── ② 표시 모드 ─────────────────────────── */}
<CPSection title="② 표시 모드" desc="테이블 형태 변형">
<CPVisualGrid
cols={5}
cardHeight={62}
value={displayMode}
onChange={(v) => patch({ displayMode: v as TableConfig["displayMode"] })}
options={[
{ value: "table", label: "기본", preview: <Table2 size={16} />, desc: "일반 테이블" },
{ value: "split", label: "분할", preview: <Columns2 size={16} />, desc: "좌우 분할" },
{ value: "grouped", label: "그룹", preview: <List size={16} />, desc: "그룹핑" },
{ value: "pivot", label: "피벗", preview: <Grid3x3 size={16} />, desc: "피벗 그리드" },
{ value: "card", label: "카드", preview: <LayoutGrid size={16} />, desc: "카드 리스트" },
]}
/>
</CPSection>
{/* ── ③ 행 선택 + 높이 ─────────────────────────── */}
<CPSection title="③ 행 / 높이">
<CPRow label="행 선택">
<CPSegment
value={current.selectionMode || "single"}
onChange={(v) => patch({ selectionMode: v as TableConfig["selectionMode"] })}
options={[
{ value: "none", label: "없음" },
{ value: "single", label: "단일" },
{ value: "multiple", label: "복수" },
]}
/>
</CPRow>
<CPRow label="행 높이">
<CPSegment
value={current.rowHeight || "normal"}
onChange={(v) => patch({ rowHeight: v as TableConfig["rowHeight"] })}
options={[
{ value: "compact", label: "좁게" },
{ value: "normal", label: "기본" },
{ value: "relaxed", label: "넓게" },
]}
/>
</CPRow>
</CPSection>
{/* ── ④ 모드별 설정 (조건부) ─────────────────────────── */}
{displayMode === "split" && (
<CPSection title="④ 분할 설정" desc="좌측 패널 비율">
<CPRow label="좌측 비율" help="0.1 ~ 0.9 (예: 0.5 = 절반)">
<CPNumber
value={current.splitRatio ?? 0.5}
onChange={(v) => patch({ splitRatio: v ?? 0.5 })}
min={0.1}
max={0.9}
step={0.05}
/>
</CPRow>
</CPSection>
)}
{displayMode === "grouped" && (
<CPSection title="④ 그룹 설정" desc="그룹화 기준 컬럼">
<CPRow label="그룹화 컬럼">
<CPSelect
value={current.groupBy || ""}
onChange={(v) => patch({ groupBy: v || undefined })}
searchable={false}
>
<option value="">...</option>
{columns.map((c) => (
<option key={c.key} value={c.key}>
{c.label || c.key}
</option>
))}
</CPSelect>
</CPRow>
{columns.length === 0 && (
<Hint tone="warn"> .</Hint>
)}
</CPSection>
)}
{displayMode === "pivot" && (
<CPSection title="④ 피벗 설정" desc="개발 중">
<Hint tone="warn">
row/column/values UI는 .
<br />
displayMode ( ).
</Hint>
</CPSection>
)}
{/* ── ⑤ 컬럼 ─────────────────────────── */}
<CPSection title="⑤ 컬럼" desc={`${columns.length}`}>
<div
style={{
display: "flex",
justifyContent: "flex-end",
marginBottom: 5,
}}
>
<button
type="button"
onClick={addColumn}
style={{
padding: "4px 10px",
fontSize: 10.5,
background: "var(--cp-bg-subtle)",
border: "1px solid var(--cp-border)",
borderRadius: 4,
cursor: "pointer",
color: "var(--cp-text)",
fontFamily: "var(--v5-font-sans)",
display: "inline-flex",
alignItems: "center",
gap: 4,
}}
>
<Plus size={10} />
</button>
</div>
{columns.length === 0 ? (
<Hint>
{connectedTable
? loadingConnectedColumns
? "컬럼 정보를 불러오는 중입니다."
: effectiveTableColumns.length > 0
? "위 [자동 로드] 버튼으로 한 번에 가져올 수 있습니다."
: "연결된 테이블에서 컬럼 정보를 찾지 못했습니다."
: "테이블을 연결하면 자동 로드할 수 있습니다."}
</Hint>
) : (
<div
style={{
border: "1px solid var(--cp-border-subtle)",
borderRadius: 5,
overflow: "hidden",
background: "var(--cp-bg-subtle)",
}}
>
{columns.map((col, idx) => (
<ColumnEditRow
key={`${col.key}-${idx}`}
index={idx}
col={col}
isLast={idx === columns.length - 1}
onChange={(p) => updateColumn(idx, p)}
onRemove={() => removeColumn(idx)}
/>
))}
</div>
)}
</CPSection>
{/* ── ▾ 동작 옵션 ─────────────────────────── */}
<CPGroup title="동작 옵션" defaultOpen={false}>
<FeatureChipGrid
items={[
{
key: "showHeader",
label: "헤더",
default: true,
desc: "컬럼 라벨이 있는 상단 헤더 행을 표시합니다.\n끄면 데이터 행만 보여요.",
},
{
key: "showFooter",
label: "푸터",
default: true,
desc: "테이블 하단에 페이지네이션 / 합계 등을 표시합니다.\n페이지네이션이 비활성이면 자리만 차지해요.",
},
{
key: "showCheckbox",
label: "체크박스",
desc: "각 행 좌측에 선택 체크박스 컬럼이 추가됩니다.\n[행 선택] = 복수 일 때 함께 켜는 게 일반적이에요.",
},
{
key: "striped",
label: "줄무늬",
default: true,
desc: "짝수 행에 옅은 음영을 주어 행 구분이 쉬워집니다.\n행이 많은 표 (50행+) 에 권장.",
},
{
key: "hoverable",
label: "호버 효과",
default: true,
desc: "행 위에 마우스를 올리면 배경이 밝아져 강조됩니다.\n인터랙티브 테이블 (행 클릭/선택) 에 유용.",
},
{
key: "showToolbar",
label: "툴바",
default: true,
desc: "테이블 상단에 검색 / 새로고침 / 엑셀 등 도구 모음을 표시합니다.\n별도 검색 컴포넌트가 있으면 OFF 권장.",
},
{
key: "bordered",
label: "테두리",
desc: "셀 사이에 보더 선을 추가합니다.\n많은 컬럼을 명확히 구분할 때 유용.",
},
]}
source={current as any}
onToggle={(k, v) => patch({ [k]: v } as Partial<TableConfig>)}
/>
</CPGroup>
</div>
);
};
InvTableConfigPanel.displayName = "InvTableConfigPanel";
// ───────────────────────────────────────────────────────
// ColumnEditRow — 컬럼 한 줄 편집 (dense)
// ───────────────────────────────────────────────────────
function ColumnEditRow({
index,
col,
isLast,
onChange,
onRemove,
}: {
index: number;
col: TableColumn;
isLast: boolean;
onChange: (p: Partial<TableColumn>) => void;
onRemove: () => void;
}) {
const [hover, setHover] = useState(false);
const [expanded, setExpanded] = useState(false);
return (
<div
onMouseEnter={() => 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 + 펼침 화살표 + 삭제 */}
<div
style={{
display: "grid",
gridTemplateColumns: "16px 1fr 1fr 18px 22px",
alignItems: "center",
columnGap: 6,
padding: "5px 8px",
minHeight: 28,
}}
>
<span
style={{
fontSize: 9,
color: "var(--cp-text-muted)",
fontFamily: "var(--v5-font-mono)",
textAlign: "right",
}}
>
{index + 1}
</span>
<input
type="text"
value={col.label}
onChange={(e) => onChange({ label: e.target.value })}
placeholder="라벨"
style={inputStyle()}
/>
<input
type="text"
value={col.key}
onChange={(e) => onChange({ key: e.target.value })}
placeholder="컬럼 key"
style={inputStyle({ mono: true })}
/>
<button
type="button"
onClick={() => setExpanded((x) => !x)}
title={expanded ? "접기" : "펼침"}
style={{
width: 18,
height: 18,
padding: 0,
background: "transparent",
border: "none",
cursor: "pointer",
color: "var(--cp-text-muted)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 10,
}}
>
{expanded ? "▾" : "▸"}
</button>
<button
type="button"
onClick={onRemove}
title="제거"
style={{
width: 22,
height: 22,
padding: 0,
background: "transparent",
border: "none",
cursor: "pointer",
color: hover ? "var(--v5-red, #ef4444)" : "transparent",
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 4,
transition: "color .12s ease, background .12s ease",
}}
onMouseEnter={(e) =>
((e.currentTarget as HTMLButtonElement).style.background =
"rgba(239, 68, 68, 0.10)")
}
onMouseLeave={(e) =>
((e.currentTarget as HTMLButtonElement).style.background = "transparent")
}
>
×
</button>
</div>
{/* 펼친 옵션: 너비 / 정렬 / 정렬 가능 */}
{expanded && (
<div
style={{
padding: "0 8px 8px 30px",
display: "flex",
flexDirection: "column",
gap: 6,
}}
>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: 6,
alignItems: "center",
}}
>
<CPRow label="너비 (px)">
<CPNumber
value={col.width ?? undefined}
onChange={(v) => onChange({ width: v })}
placeholder="auto"
min={0}
/>
</CPRow>
<CPRow label="정렬">
<CPSegment
value={col.align || "left"}
onChange={(v) => onChange({ align: v as TableColumn["align"] })}
options={[
{ value: "left", label: <AlignLeft size={11} /> },
{ value: "center", label: <AlignCenter size={11} /> },
{ value: "right", label: <AlignRight size={11} /> },
]}
/>
</CPRow>
</div>
<CPRow label="정렬 가능" help="헤더 클릭 시 sort 토글">
<CPSwitch
value={col.sortable ?? true}
onChange={(v) => onChange({ sortable: v })}
/>
</CPRow>
</div>
)}
</div>
);
}
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;