Files
invyone/frontend/lib/registry/components/table/InvTableConfigPanel.tsx
T
DDD1542 7d204bfffd
Build & Deploy to K8s / build-and-deploy (push) Failing after 14m3s
refactor: complete canonical table cleanup
2026-05-21 11:55:08 +09:00

2329 lines
81 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,
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,
TableActionItemConfig,
TableActionType,
} 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 ?? [];
// canonical: selectedTable 우선, legacy layout 의 tableName 도 fallback 흡수 (Phase C.1).
// useCustomTable=true 면 customTableName 이 effective table — 그러나 ConfigPanel 의 "연결" 위젯은
// 항상 selectedTable 만 편집한다 (customTableName 은 별도 입력 필드로 노출).
const connectedTable =
current.selectedTable || current.tableName || screenTableName;
const { options: tableOptions } = useDbTables({ fallback: 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";
// ─── 사용 가능한 컬럼 옵션 (Phase C.3 — 필터 row 의 column select 용) ───
// 1) canonical columns 우선
// 2) connected DB metadata fallback
const availableColumnOptions = useMemo(() => {
const seen = new Set<string>();
const normalize = (value: unknown, label: unknown): ColOpt | null => {
if (typeof value !== "string" || value.length === 0 || seen.has(value)) {
return null;
}
seen.add(value);
return {
value,
label:
typeof label === "string" && label.length > 0
? label
: value,
};
};
if (columns.length > 0) {
return columns
.map((c) => normalize(c.key, c.label || c.key))
.filter((option): option is ColOpt => option != null);
}
return (effectiveTableColumns || []).flatMap((c: any) => {
const value =
c.key || c.columnName || c.column_name || c.name || "";
const label =
c.label ||
c.displayName ||
c.columnLabel ||
c.column_label ||
c.columnName ||
c.column_name ||
c.name ||
value;
const option = normalize(value, label);
return option ? [option] : [];
});
}, [columns, effectiveTableColumns]);
// ─── 필터 helpers (Phase C.3) ───
const patchFilter = (next: Partial<NonNullable<TableConfig["filter"]>>) =>
patch({ filter: { enabled: false, filters: [], ...current.filter, ...next } as any });
const patchExcludeFilter = (next: Partial<NonNullable<TableConfig["excludeFilter"]>>) =>
patch({
excludeFilter: {
enabled: false,
referenceTable: "",
referenceColumn: "",
sourceColumn: "",
...current.excludeFilter,
...next,
} as any,
});
const patchDataFilter = (next: Partial<NonNullable<TableConfig["dataFilter"]>>) =>
patch({
dataFilter: {
enabled: false,
filters: [],
match_type: "all",
...current.dataFilter,
...next,
} as any,
});
// ─── 액션 helper (Phase C.4) ───
// toggling `showActions` 가 기존 `actions.actions` 를 덮지 않도록 (또는 `bulkActions` 가
// `bulkActionList` 를 덮지 않도록) 항상 기존 actions 객체 먼저 spread 한 뒤 next 가 덮어쓴다.
const patchActions = (
next: Partial<NonNullable<TableConfig["actions"]>>,
) =>
patch({
actions: {
showActions: false,
actions: [],
bulkActions: false,
bulkActionList: [],
...current.actions,
...next,
} as any,
});
return (
<div style={{ fontFamily: "var(--v5-font-sans)", color: "var(--cp-text)", padding: "0 12px" }}>
{/* ── ① 테이블 연결 ─────────────────────────── */}
<TableConnectSection
value={connectedTable || ""}
onChange={(v) => {
onTableChange?.(v);
// canonical 정규 키만 set. legacy alias (tableName) 는 유지하지 않는다 — 새 ConfigPanel
// 에서 편집된 결과는 selectedTable 단일 키로 저장.
patch({ selectedTable: v, columns: [] } as any);
}}
options={tableOptions}
desc="DB 테이블 매핑"
>
{connectedTable && (
<Hint>
{loadingConnectedColumns
? "컬럼 정보를 불러오는 중..."
: effectiveTableColumns.length > 0
? `컬럼 ${effectiveTableColumns.length}개 준비됨`
: "불러온 컬럼 정보가 없습니다"}
</Hint>
)}
{connectedTable && effectiveTableColumns.length > 0 && (
<AutoLoadButton
label={`DB 컬럼에서 자동 로드 (${effectiveTableColumns.length}개)`}
onClick={autoLoadColumns}
/>
)}
{/* ── 데이터 소스 옵션 (Phase C.1, 2026-05-20) ───────────────
customTableName / useCustomTable / isReadOnly / autoLoad 를 canonical
TableConfig 에 흡수. */}
<CPGroup title="데이터 소스 옵션" defaultOpen={false}>
<CPRow
label="커스텀 테이블 사용"
help="화면 메인 테이블 대신 이 컴포넌트 전용 테이블을 사용"
>
<CPSwitch
value={!!current.useCustomTable}
onChange={(next) => patch({ useCustomTable: next })}
/>
</CPRow>
{current.useCustomTable && (
<CPRow
label="커스텀 테이블명"
help="DB 테이블명 (예: tb_customer)"
>
<CPText
value={current.customTableName ?? ""}
onChange={(v) => patch({ customTableName: v })}
placeholder="예: tb_customer"
/>
</CPRow>
)}
<CPRow
label="읽기 전용"
help="조회용 테이블. 인라인 편집 / 행 추가·삭제 비활성"
>
<CPSwitch
value={!!current.isReadOnly}
onChange={(next) => patch({ isReadOnly: next })}
/>
</CPRow>
<CPRow
label="자동 로드"
help="마운트 시 데이터를 자동 fetch. 끄면 초기 로드를 보류하며 수동 refresh wiring 은 후속 단계에서 연결"
>
<CPSwitch
value={current.autoLoad !== false}
onChange={(next) => patch({ autoLoad: next })}
/>
</CPRow>
</CPGroup>
</TableConnectSection>
{/* ── ② 표시 모드 ─────────────────────────── */}
<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="본체 분석 UI에서 사용할 표시 옵션">
{/* 피벗 필드 배치는 PivotView 본체의 FieldPanel/FieldChooser가 담당한다. ConfigPanel은 메타 토글만 관리한다. */}
<FeatureChipGrid
items={[
{ key: "chartEnabled", label: "차트 표시", desc: "본체 안에 피벗 차트(bar/line/pie 등) 패널을 함께 표시합니다." },
{ key: "fieldChooserEnabled", label: "필드 선택기", desc: "본체에서 row/column/data/filter 영역에 컬럼을 드래그-앤-드롭 배치하는 모달을 활성화." },
{ key: "rowGrandTotals", label: "행 총계", desc: "각 행의 합계 행을 자동 표시합니다. row 영역 컬럼이 있을 때 의미가 있습니다." },
{ key: "columnGrandTotals", label: "열 총계", desc: "각 열의 합계 열을 자동 표시합니다. column 영역 컬럼이 있을 때 의미가 있습니다." },
{ key: "mergeCells", label: "셀 병합", desc: "같은 값의 인접 셀을 병합해 가독성을 높입니다." },
{ key: "alternateRowColors", label: "행 교대 색", desc: "홀수/짝수 행을 다른 톤으로 교대 표시 (Zebra)." },
{ key: "exportExcel", label: "엑셀 내보내기", desc: "본체 툴바에 .xlsx 다운로드 버튼을 활성화." },
{ key: "exportPdf", label: "PDF 내보내기", desc: "본체 툴바에 PDF 다운로드 버튼을 활성화." },
]}
source={{
chartEnabled: current.pivotChart?.enabled ?? false,
fieldChooserEnabled: current.pivotFieldChooser?.enabled ?? false,
rowGrandTotals: current.pivotTotals?.showRowGrandTotals ?? false,
columnGrandTotals: current.pivotTotals?.showColumnGrandTotals ?? false,
mergeCells: current.pivotStyle?.mergeCells ?? false,
alternateRowColors: current.pivotStyle?.alternateRowColors ?? false,
exportExcel: current.pivotExportConfig?.excel ?? false,
exportPdf: current.pivotExportConfig?.pdf ?? false,
}}
onToggle={(key, value) => {
switch (key) {
case "chartEnabled":
patch({
pivotChart: {
type: current.pivotChart?.type ?? "bar",
position: current.pivotChart?.position ?? "bottom",
...current.pivotChart,
enabled: value,
},
});
return;
case "fieldChooserEnabled":
patch({
pivotFieldChooser: { ...current.pivotFieldChooser, enabled: value },
});
return;
case "rowGrandTotals":
patch({
pivotTotals: { ...current.pivotTotals, showRowGrandTotals: value },
});
return;
case "columnGrandTotals":
patch({
pivotTotals: { ...current.pivotTotals, showColumnGrandTotals: value },
});
return;
case "mergeCells":
case "alternateRowColors": {
const baseStyle = {
theme: current.pivotStyle?.theme ?? "default" as const,
headerStyle: current.pivotStyle?.headerStyle ?? "default" as const,
cellPadding: current.pivotStyle?.cellPadding ?? "normal" as const,
borderStyle: current.pivotStyle?.borderStyle ?? "light" as const,
...current.pivotStyle,
};
patch({
pivotStyle: { ...baseStyle, [key]: value },
});
return;
}
case "exportExcel":
patch({
pivotExportConfig: { ...current.pivotExportConfig, excel: value },
});
return;
case "exportPdf":
patch({
pivotExportConfig: { ...current.pivotExportConfig, pdf: value },
});
return;
}
}}
/>
</CPSection>
)}
{displayMode === "card" && (
<CPSection title="④ 카드 설정" desc="grid · 표시 영역 · 컬럼 매핑">
<CPRow label="한 줄 카드 수">
<CPNumber
value={current.cardsPerRow ?? 3}
onChange={(v) => patch({ cardsPerRow: v ?? 3 })}
min={1}
max={10}
/>
</CPRow>
<CPRow label="간격(px)">
<CPNumber
value={current.cardSpacing ?? 12}
onChange={(v) => patch({ cardSpacing: v ?? 12 })}
min={0}
max={64}
/>
</CPRow>
<CPGroup title="표시 영역" defaultOpen>
<CPRow label="제목">
<CPSwitch
value={current.cardStyle?.showTitle ?? true}
onChange={(v) => patch({ cardStyle: { ...current.cardStyle, showTitle: v } })}
/>
</CPRow>
<CPRow label="부제">
<CPSwitch
value={current.cardStyle?.showSubtitle ?? true}
onChange={(v) => patch({ cardStyle: { ...current.cardStyle, showSubtitle: v } })}
/>
</CPRow>
<CPRow label="설명">
<CPSwitch
value={current.cardStyle?.showDescription ?? true}
onChange={(v) => patch({ cardStyle: { ...current.cardStyle, showDescription: v } })}
/>
</CPRow>
<CPRow label="이미지">
<CPSwitch
value={current.cardStyle?.showImage ?? true}
onChange={(v) => patch({ cardStyle: { ...current.cardStyle, showImage: v } })}
/>
</CPRow>
{(current.cardStyle?.showImage ?? true) && (
<>
<CPRow label="이미지 위치">
<CPSegment
value={current.cardStyle?.imagePosition || "top"}
onChange={(v) => patch({ cardStyle: { ...current.cardStyle, imagePosition: v as any } })}
options={[
{ value: "top", label: "상단" },
{ value: "left", label: "좌측" },
{ value: "right", label: "우측" },
]}
/>
</CPRow>
<CPRow label="이미지 크기">
<CPSegment
value={current.cardStyle?.imageSize || "medium"}
onChange={(v) => patch({ cardStyle: { ...current.cardStyle, imageSize: v as any } })}
options={[
{ value: "small", label: "작게" },
{ value: "medium", label: "보통" },
{ value: "large", label: "크게" },
]}
/>
</CPRow>
</>
)}
</CPGroup>
<CPGroup title="컬럼 매핑" defaultOpen>
{columns.length === 0 ? (
<Hint tone="warn"> .</Hint>
) : (
<>
<CPRow label="제목 컬럼">
<CPSelect
value={current.cardColumnMapping?.titleColumn || ""}
onChange={(v) =>
patch({
cardColumnMapping: {
...current.cardColumnMapping,
titleColumn: v || undefined,
},
})
}
>
<option value="">...</option>
{columns.map((c) => (
<option key={c.key} value={c.key}>
{c.label || c.key}
</option>
))}
</CPSelect>
</CPRow>
<CPRow label="부제 컬럼">
<CPSelect
value={current.cardColumnMapping?.subtitleColumn || ""}
onChange={(v) =>
patch({
cardColumnMapping: {
...current.cardColumnMapping,
subtitleColumn: v || undefined,
},
})
}
>
<option value="">...</option>
{columns.map((c) => (
<option key={c.key} value={c.key}>
{c.label || c.key}
</option>
))}
</CPSelect>
</CPRow>
<CPRow label="설명 컬럼">
<CPSelect
value={current.cardColumnMapping?.descriptionColumn || ""}
onChange={(v) =>
patch({
cardColumnMapping: {
...current.cardColumnMapping,
descriptionColumn: v || undefined,
},
})
}
>
<option value="">...</option>
{columns.map((c) => (
<option key={c.key} value={c.key}>
{c.label || c.key}
</option>
))}
</CPSelect>
</CPRow>
<CPRow label="이미지 컬럼">
<CPSelect
value={current.cardColumnMapping?.imageColumn || ""}
onChange={(v) =>
patch({
cardColumnMapping: {
...current.cardColumnMapping,
imageColumn: v || undefined,
},
})
}
>
<option value="">...</option>
{columns.map((c) => (
<option key={c.key} value={c.key}>
{c.label || c.key}
</option>
))}
</CPSelect>
</CPRow>
</>
)}
</CPGroup>
<CPGroup title="액션 버튼" defaultOpen={false}>
<CPRow label="액션 표시">
<CPSwitch
value={current.cardStyle?.showActions ?? false}
onChange={(v) => patch({ cardStyle: { ...current.cardStyle, showActions: v } })}
/>
</CPRow>
{current.cardStyle?.showActions && (
<>
<CPRow label="보기">
<CPSwitch
value={current.cardStyle?.showViewButton ?? false}
onChange={(v) =>
patch({ cardStyle: { ...current.cardStyle, showViewButton: v } })
}
/>
</CPRow>
<CPRow label="편집">
<CPSwitch
value={current.cardStyle?.showEditButton ?? false}
onChange={(v) =>
patch({ cardStyle: { ...current.cardStyle, showEditButton: v } })
}
/>
</CPRow>
<CPRow label="삭제">
<CPSwitch
value={current.cardStyle?.showDeleteButton ?? false}
onChange={(v) =>
patch({ cardStyle: { ...current.cardStyle, showDeleteButton: v } })
}
/>
</CPRow>
</>
)}
</CPGroup>
</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>
{/* ── ⑥ 페이지네이션 (Phase C.5) ─────────────────────────── */}
<CPGroup title="⑥ 페이지네이션" defaultOpen={false}>
<CPRow label="활성" help="페이지네이션 사용 여부 (끄면 전체 데이터 한 번에)">
<CPSwitch
value={current.pagination?.enabled !== false}
onChange={(v) =>
patch({ pagination: { ...current.pagination, enabled: v } })
}
/>
</CPRow>
<CPRow label="페이지 크기" help="한 페이지에 표시할 행 수">
<CPNumber
value={current.pagination?.pageSize ?? 20}
onChange={(v) =>
patch({ pagination: { ...current.pagination, pageSize: v ?? 20 } })
}
min={1}
max={1000}
/>
</CPRow>
<CPRow
label="페이지 크기 옵션"
help="콤마로 구분. 사용자가 드롭다운에서 선택 (예: 10, 20, 50, 100)"
>
<input
type="text"
value={
Array.isArray(current.pagination?.pageSizeOptions)
? current.pagination!.pageSizeOptions!.join(", ")
: ""
}
onChange={(e) => {
const parsed = e.target.value
.split(",")
.map((s) => parseInt(s.trim(), 10))
.filter((n) => Number.isFinite(n) && n > 0);
patch({
pagination: {
...current.pagination,
pageSizeOptions: parsed.length > 0 ? parsed : undefined,
},
});
}}
placeholder="10, 20, 50, 100"
style={{
height: 22,
padding: "0 6px",
fontSize: 11,
fontFamily: "var(--v5-font-mono)",
background: "var(--cp-surface)",
border: "1px solid var(--cp-border)",
borderRadius: 3,
color: "var(--cp-text)",
outline: "none",
minWidth: 0,
width: "100%",
}}
/>
</CPRow>
<CPRow label="크기 선택 드롭다운" help="D.6 에서 footer 에 노출">
<CPSwitch
value={!!current.pagination?.showSizeSelector}
onChange={(v) =>
patch({
pagination: { ...current.pagination, showSizeSelector: v },
})
}
/>
</CPRow>
<CPRow label="페이지 정보" help='"1-20 / 총 100건" 표시 (D.6 footer)'>
<CPSwitch
value={!!current.pagination?.showPageInfo}
onChange={(v) =>
patch({
pagination: { ...current.pagination, showPageInfo: v },
})
}
/>
</CPRow>
</CPGroup>
{/* ── ⑦ 데이터 동작 (Phase C.5) ─────────────────────────── */}
<CPGroup title="⑦ 데이터 동작" defaultOpen={false}>
<CPRow label="기본 정렬 컬럼" help="화면 진입 시 적용될 정렬 기준">
<CPSelect
value={current.defaultSort?.columnName || ""}
onChange={(v) =>
patch({
defaultSort: v
? {
columnName: v,
direction: current.defaultSort?.direction || "asc",
}
: undefined,
})
}
sortable={false}
options={[
{ value: "", label: "(없음)" },
...columns.map((c) => ({ value: c.key, label: c.label })),
]}
/>
</CPRow>
{current.defaultSort?.columnName && (
<CPRow label="정렬 방향">
<CPSegment
value={current.defaultSort.direction}
onChange={(v) =>
patch({
defaultSort: {
columnName: current.defaultSort!.columnName,
direction: v as "asc" | "desc",
},
})
}
options={[
{ value: "asc", label: "오름" },
{ value: "desc", label: "내림" },
]}
/>
</CPRow>
)}
<CPRow label="자동 새로고침 (초)" help="0 또는 빈 값 = 비활성. >0 일 때 주기적 refresh">
<CPNumber
value={current.refreshInterval ?? undefined}
onChange={(v) =>
patch({
refreshInterval: v && v > 0 ? v : undefined,
})
}
placeholder="0"
min={0}
/>
</CPRow>
</CPGroup>
{/* ── ⑧ 스타일 (Phase C.5) ─────────────────────────── */}
<CPGroup title="⑧ 스타일" defaultOpen={false}>
<CPRow label="테마" help="시각 변형 (D.6 에서 본체 적용)">
<CPSelect
value={current.tableStyle?.theme || "default"}
onChange={(v) =>
patch({
tableStyle: {
...current.tableStyle,
theme: v as "default" | "striped" | "bordered" | "minimal",
},
})
}
sortable={false}
options={[
{ value: "default", label: "기본" },
{ value: "striped", label: "줄무늬" },
{ value: "bordered", label: "테두리" },
{ value: "minimal", label: "미니멀" },
]}
/>
</CPRow>
<CPRow label="헤더 스타일" help="D.6 에서 wiring">
<CPSelect
value={current.tableStyle?.headerStyle || "default"}
onChange={(v) =>
patch({
tableStyle: {
...current.tableStyle,
headerStyle: v as "default" | "dark" | "light",
},
})
}
sortable={false}
options={[
{ value: "default", label: "기본" },
{ value: "dark", label: "다크" },
{ value: "light", label: "라이트" },
]}
/>
</CPRow>
<CPRow label="테두리 두께" help="D.6 에서 wiring">
<CPSelect
value={current.tableStyle?.borderStyle || "light"}
onChange={(v) =>
patch({
tableStyle: {
...current.tableStyle,
borderStyle: v as "none" | "light" | "heavy",
},
})
}
sortable={false}
options={[
{ value: "none", label: "없음" },
{ value: "light", label: "얇음" },
{ value: "heavy", label: "두꺼움" },
]}
/>
</CPRow>
<CPRow
label="교차 행 음영"
help="striped 와 동일. 양쪽 키가 모두 인식됨"
>
<CPSwitch
value={
current.tableStyle?.alternateRows ??
(current as any).striped ??
true
}
onChange={(v) =>
patch({
tableStyle: { ...current.tableStyle, alternateRows: v },
striped: v,
})
}
/>
</CPRow>
<CPRow
label="호버 효과"
help="hoverable 과 동일. 양쪽 키가 모두 인식됨"
>
<CPSwitch
value={
current.tableStyle?.hoverEffect ??
(current as any).hoverable ??
true
}
onChange={(v) =>
patch({
tableStyle: { ...current.tableStyle, hoverEffect: v },
hoverable: v,
})
}
/>
</CPRow>
</CPGroup>
{/* ── ⑨ 툴바 버튼 (Phase C.5 — 실제 버튼 동작은 D.6) ─────────────────────────── */}
<CPGroup title="⑨ 툴바 버튼" defaultOpen={false}>
<Hint>
8 / . 6 ( / PDF / /
/ / ) D.6 + wiring.
</Hint>
<CPRow label="새로고침" help="root showRefresh 와 동일 — 양쪽 인식">
<CPSwitch
value={
current.toolbar?.showRefresh ?? !!(current as any).showRefresh
}
onChange={(v) =>
patch({
toolbar: { ...current.toolbar, showRefresh: v },
showRefresh: v,
})
}
/>
</CPRow>
<CPRow label="엑셀 내보내기" help="root showExcel 와 동일 — 양쪽 인식">
<CPSwitch
value={current.toolbar?.showExcel ?? !!(current as any).showExcel}
onChange={(v) =>
patch({
toolbar: { ...current.toolbar, showExcel: v },
showExcel: v,
})
}
/>
</CPRow>
<CPRow label="편집 모드 토글" help="D.6">
<CPSwitch
value={!!current.toolbar?.showEditMode}
onChange={(v) =>
patch({ toolbar: { ...current.toolbar, showEditMode: v } })
}
/>
</CPRow>
<CPRow label="PDF 내보내기" help="D.6">
<CPSwitch
value={!!current.toolbar?.showPdf}
onChange={(v) =>
patch({ toolbar: { ...current.toolbar, showPdf: v } })
}
/>
</CPRow>
<CPRow label="복사 (클립보드)" help="D.6">
<CPSwitch
value={!!current.toolbar?.showCopy}
onChange={(v) =>
patch({ toolbar: { ...current.toolbar, showCopy: v } })
}
/>
</CPRow>
<CPRow label="자체 검색바" help="별도 Search 컴포넌트와 중복 시 OFF — D.6">
<CPSwitch
value={!!current.toolbar?.showSearch}
onChange={(v) =>
patch({ toolbar: { ...current.toolbar, showSearch: v } })
}
/>
</CPRow>
<CPRow label="자체 필터 패널" help="D.6">
<CPSwitch
value={!!current.toolbar?.showFilter}
onChange={(v) =>
patch({ toolbar: { ...current.toolbar, showFilter: v } })
}
/>
</CPRow>
<CPRow label="페이지네이션 새로고침" help="D.6 footer 영역">
<CPSwitch
value={!!current.toolbar?.showPaginationRefresh}
onChange={(v) =>
patch({
toolbar: { ...current.toolbar, showPaginationRefresh: v },
})
}
/>
</CPRow>
</CPGroup>
{/* ── ⑩ 필터 (Phase C.3 — runtime 적용은 D.2) ─────────────────────────── */}
<CPGroup title="⑩ 필터" defaultOpen={false}>
<Hint>
C.3 config + UI . / source /
sub-query / DataFilter D.2 .
</Hint>
{/* ── 검색 필터 위젯 (filter) ── */}
<SubSectionHeading> </SubSectionHeading>
<CPRow label="활성">
<CPSwitch
value={!!current.filter?.enabled}
onChange={(v) => patchFilter({ enabled: v })}
/>
</CPRow>
<CPRow label="필터-리스트 간격 (px)" help="검색 영역과 테이블 사이 간격">
<CPNumber
value={current.filter?.bottomSpacing ?? undefined}
onChange={(v) => patchFilter({ bottomSpacing: v })}
placeholder="40"
min={0}
/>
</CPRow>
<FilterWidgetList
filters={current.filter?.filters || []}
columnOptions={availableColumnOptions}
onChange={(filters) => patchFilter({ filters })}
/>
{/* ── 연결 필터 (linkedFilters) ── */}
<SubSectionHeading> </SubSectionHeading>
<Hint>
( ) .
</Hint>
<LinkedFilterList
items={current.linkedFilters || []}
columnOptions={availableColumnOptions}
onChange={(linkedFilters) => patch({ linkedFilters })}
/>
{/* ── 제외 필터 (excludeFilter) ── */}
<SubSectionHeading> </SubSectionHeading>
<Hint>
row (: 이미
).
</Hint>
<CPRow label="활성">
<CPSwitch
value={!!current.excludeFilter?.enabled}
onChange={(v) => patchExcludeFilter({ enabled: v })}
/>
</CPRow>
<CPRow label="참조 테이블" help="row 를 보유한 비교 대상 테이블 (예: customer_item_mapping)">
<input
type="text"
value={current.excludeFilter?.referenceTable ?? ""}
onChange={(e) => patchExcludeFilter({ referenceTable: e.target.value })}
placeholder="예: customer_item_mapping"
style={inputStyle()}
/>
</CPRow>
<CPRow label="참조 컬럼" help="참조 테이블의 비교 컬럼 (예: item_id)">
<input
type="text"
value={current.excludeFilter?.referenceColumn ?? ""}
onChange={(e) => patchExcludeFilter({ referenceColumn: e.target.value })}
placeholder="예: item_id"
style={inputStyle()}
/>
</CPRow>
<CPRow label="본 테이블 컬럼" help="비교 대상 컬럼 (예: item_number)">
<CPSelect
value={current.excludeFilter?.sourceColumn ?? ""}
onChange={(v) => patchExcludeFilter({ sourceColumn: v })}
sortable={false}
options={[
{ value: "", label: "(선택)" },
...availableColumnOptions,
]}
/>
</CPRow>
<CPRow label="필터 컬럼" help="참조 테이블의 필터 컬럼 (예: customer_id)">
<input
type="text"
value={current.excludeFilter?.filterColumn ?? ""}
onChange={(e) =>
patchExcludeFilter({ filterColumn: e.target.value || undefined })
}
placeholder="예: customer_id"
style={inputStyle()}
/>
</CPRow>
<CPRow label="필터 값 소스">
<CPSegment
value={current.excludeFilter?.filterValueSource || "url"}
onChange={(v) =>
patchExcludeFilter({
filterValueSource: v as "url" | "formData" | "parentData",
})
}
options={[
{ value: "url", label: "URL" },
{ value: "formData", label: "폼" },
{ value: "parentData", label: "상위" },
]}
/>
</CPRow>
<CPRow label="필터 값 필드명" help="예: customer_code">
<input
type="text"
value={current.excludeFilter?.filterValueField ?? ""}
onChange={(e) =>
patchExcludeFilter({ filterValueField: e.target.value || undefined })
}
placeholder="예: customer_code"
style={inputStyle()}
/>
</CPRow>
{/* ── 데이터 필터 (dataFilter) ── */}
<SubSectionHeading> ()</SubSectionHeading>
<Hint>
14 operator (equals / in / between / date_range_contains ) row .
</Hint>
<CPRow label="활성">
<CPSwitch
value={!!current.dataFilter?.enabled}
onChange={(v) => patchDataFilter({ enabled: v })}
/>
</CPRow>
<CPRow label="조건 결합" help="모든 조건 AND / 하나 이상 OR">
<CPSegment
value={current.dataFilter?.match_type || "all"}
onChange={(v) =>
patchDataFilter({ match_type: v as "all" | "any" })
}
options={[
{ value: "all", label: "모두" },
{ value: "any", label: "하나 이상" },
]}
/>
</CPRow>
<DataFilterList
filters={current.dataFilter?.filters || []}
columnOptions={availableColumnOptions}
onChange={(filters) => patchDataFilter({ filters })}
/>
</CPGroup>
{/* ── ⑪ 액션 (Phase C.4 — runtime 렌더/실행은 D.4) ─────────────────────────── */}
<CPGroup title="⑪ 액션" defaultOpen={false}>
<Hint>
Table-level row/bulk . C.4 config . / click
(navigation / / API / custom ) D.4.
(cardStyle.showActions ) layer.
</Hint>
{/* ── 행 액션 (actions.actions[]) ── */}
<SubSectionHeading> </SubSectionHeading>
<CPRow label="활성" help="액션 컬럼 표시 (D.4)">
<CPSwitch
value={!!current.actions?.showActions}
onChange={(v) => patchActions({ showActions: v })}
/>
</CPRow>
<ActionItemList
items={current.actions?.actions || []}
onChange={(actions) => patchActions({ actions })}
/>
{/* ── 일괄 액션 (actions.bulkActions / bulkActionList) ── */}
<SubSectionHeading> </SubSectionHeading>
<CPRow label="활성" help="선택된 행들에 대한 일괄 작업 영역 표시 (D.4)">
<CPSwitch
value={!!current.actions?.bulkActions}
onChange={(v) => patchActions({ bulkActions: v })}
/>
</CPRow>
<CPRow
label="일괄 액션 종류"
help="콤마 구분 (예: delete, export). 실제 실행 매핑은 D.4"
>
<input
type="text"
value={
Array.isArray(current.actions?.bulkActionList)
? current.actions!.bulkActionList.join(", ")
: ""
}
onChange={(e) => {
const arr = e.target.value
.split(",")
.map((s) => s.trim())
.filter(Boolean);
patchActions({ bulkActionList: arr });
}}
placeholder="예: delete, export"
style={inputStyle()}
/>
</CPRow>
</CPGroup>
{/* ── ▾ 동작 옵션 ─────────────────────────── */}
<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) => {
if (k === "striped") {
patch({
striped: v,
tableStyle: { ...current.tableStyle, alternateRows: v },
});
return;
}
if (k === "hoverable") {
patch({
hoverable: v,
tableStyle: { ...current.tableStyle, hoverEffect: v },
});
return;
}
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,
}}
>
<RowNumberBadge n={index + 1} />
<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 })}
/>
<RowExpandChevron
expanded={expanded}
onToggle={() => setExpanded((x) => !x)}
/>
<RowDeleteBtn onClick={onRemove} visible={hover} />
</div>
{/* 펼친 옵션 (Phase C.2 풀 옵션) */}
{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>
{/* 고정 + 입력 타입 */}
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: 6,
alignItems: "center",
}}
>
<CPRow label="고정" help="좌/우 sticky 컬럼. 실제 렌더 적용은 D.1">
<CPSegment
value={col.fixed === "left" ? "left" : col.fixed === "right" ? "right" : "none"}
onChange={(v) =>
onChange({
fixed:
v === "left" ? "left" : v === "right" ? "right" : false,
})
}
options={[
{ value: "left", label: "좌" },
{ value: "none", label: "—" },
{ value: "right", label: "우" },
]}
/>
</CPRow>
<CPRow label="입력 타입" help="셀 렌더/인라인 편집 모드. D.3/D.5 에서 wiring">
<CPSelect
value={col.inputType || ""}
onChange={(v) => onChange({ inputType: v || undefined })}
sortable={false}
options={[
{ value: "", label: "(자동)" },
{ value: "text", label: "텍스트" },
{ value: "number", label: "숫자" },
{ value: "date", label: "날짜" },
{ value: "datetime", label: "일시" },
{ value: "select", label: "선택" },
{ value: "entity", label: "엔티티" },
{ value: "checkbox", label: "체크박스" },
{ value: "textarea", label: "텍스트영역" },
{ value: "file", label: "파일" },
{ value: "image", label: "이미지" },
{ value: "code", label: "코드" },
]}
/>
</CPRow>
</div>
{/* 동작 — sortable / searchable / editable */}
<CPRow label="정렬 가능" help="헤더 클릭 시 sort 토글">
<CPSwitch
value={col.sortable ?? true}
onChange={(v) => onChange({ sortable: v })}
/>
</CPRow>
<CPRow label="검색 가능" help="검색 위젯에 노출 — C.3 에서 wiring">
<CPSwitch
value={col.searchable ?? false}
onChange={(v) => onChange({ searchable: v })}
/>
</CPRow>
<CPRow label="편집 가능" help="인라인 편집 허용 (기본 true) — D.3 에서 wiring">
<CPSwitch
value={col.editable !== false}
onChange={(v) => onChange({ editable: v })}
/>
</CPRow>
{/* 표시 — visible / hidden */}
<CPRow label="표시" help="false 면 운영/디자인 모두 안 보임">
<CPSwitch
value={col.visible !== false}
onChange={(v) => onChange({ visible: v })}
/>
</CPRow>
<CPRow label="숨김 (디자인 도구용)" help="디자인 모드 흐릿 + 운영 숨김. visible 과 별개">
<CPSwitch
value={!!col.hidden}
onChange={(v) => onChange({ hidden: v })}
/>
</CPRow>
{/* 포맷 + 천단위 */}
<CPRow label="포맷" help="표시 포맷 문자열 (예: ###,### / YYYY-MM-DD)">
<input
type="text"
value={col.format ?? ""}
onChange={(e) => onChange({ format: e.target.value || undefined })}
placeholder="예: ###,### / YYYY-MM-DD"
style={inputStyle()}
/>
</CPRow>
{(col.inputType === "number" ||
col.format === "number" ||
col.format === "currency") && (
<CPRow label="천단위 구분" help="천단위 콤마 표시 (기본 true)">
<CPSwitch
value={col.thousandSeparator !== false}
onChange={(v) => onChange({ thousandSeparator: 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,
};
}
// ───────────────────────────────────────────────────────
// Phase C.3 (2026-05-20) — 필터 sub-editor helpers
// ───────────────────────────────────────────────────────
type ColOpt = { value: string; label: string };
function SubSectionHeading({ children }: { children: React.ReactNode }) {
return (
<div
style={{
marginTop: 10,
marginBottom: 4,
fontFamily: "var(--v5-font-sans)",
fontSize: 11,
fontWeight: 700,
color: "var(--cp-text-muted)",
textTransform: "uppercase",
letterSpacing: 0,
}}
>
{children}
</div>
);
}
function AddRowButton({
label,
onClick,
}: {
label: string;
onClick: () => void;
}) {
return (
<div
style={{
display: "flex",
justifyContent: "flex-end",
marginTop: 4,
marginBottom: 4,
}}
>
<button
type="button"
onClick={onClick}
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} /> {label}
</button>
</div>
);
}
function RemoveRowButton({ onClick }: { onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
style={{
padding: "0 6px",
height: 22,
fontSize: 11,
background: "transparent",
border: "1px solid var(--cp-border)",
borderRadius: 3,
color: "var(--cp-text-muted)",
cursor: "pointer",
}}
title="삭제"
>
×
</button>
);
}
const WIDGET_TYPE_OPTIONS: ColOpt[] = [
{ value: "text", label: "텍스트" },
{ value: "number", label: "숫자" },
{ value: "date", label: "날짜" },
{ value: "select", label: "선택" },
{ value: "entity", label: "엔티티" },
{ value: "code", label: "공통코드" },
{ value: "checkbox", label: "체크박스" },
];
// ──── FilterWidgetList (filter.filters[]) ────
function FilterWidgetList({
filters,
columnOptions,
onChange,
}: {
filters: NonNullable<TableConfig["filter"]>["filters"];
columnOptions: ColOpt[];
onChange: (next: NonNullable<TableConfig["filter"]>["filters"]) => void;
}) {
const update = (idx: number, p: Partial<(typeof filters)[number]>) =>
onChange(filters.map((f, i) => (i === idx ? { ...f, ...p } : f)));
const remove = (idx: number) => onChange(filters.filter((_, i) => i !== idx));
const add = () =>
onChange([
...filters,
{
columnName: columnOptions[0]?.value || "",
widgetType: "text",
label: "",
gridColumns: 3,
},
]);
return (
<>
{filters.length === 0 ? (
<Hint> . [+ ] row .</Hint>
) : (
<div
style={{
border: "1px solid var(--cp-border-subtle)",
borderRadius: 5,
overflow: "hidden",
background: "var(--cp-bg-subtle)",
}}
>
{filters.map((f, idx) => (
<div
key={`${f.columnName}-${idx}`}
style={{
padding: "6px 8px",
borderBottom:
idx === filters.length - 1
? "none"
: "1px solid var(--cp-border-subtle)",
display: "flex",
flexDirection: "column",
gap: 4,
}}
>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr 1fr 60px 22px",
gap: 4,
alignItems: "center",
}}
>
<CPSelect
value={f.columnName}
onChange={(v) => update(idx, { columnName: v })}
sortable={false}
options={[{ value: "", label: "(컬럼)" }, ...columnOptions]}
/>
<CPSelect
value={f.widgetType}
onChange={(v) => update(idx, { widgetType: v })}
sortable={false}
options={WIDGET_TYPE_OPTIONS}
/>
<input
type="text"
value={f.label}
onChange={(e) => update(idx, { label: e.target.value })}
placeholder="라벨"
style={inputStyle()}
/>
<CPNumber
value={f.gridColumns}
onChange={(v) => update(idx, { gridColumns: v ?? 3 })}
min={1}
max={12}
/>
<RemoveRowButton onClick={() => remove(idx)} />
</div>
{f.widgetType === "number" && (
<CPRow label="숫자 모드">
<CPSegment
value={f.numberFilterMode || "exact"}
onChange={(v) =>
update(idx, {
numberFilterMode: v as "exact" | "range",
})
}
options={[
{ value: "exact", label: "단일" },
{ value: "range", label: "범위" },
]}
/>
</CPRow>
)}
{f.widgetType === "code" && (
<CPRow label="공통코드 그룹">
<input
type="text"
value={f.codeInfo ?? ""}
onChange={(e) =>
update(idx, { codeInfo: e.target.value || undefined })
}
placeholder="예: ORD_STATUS"
style={inputStyle({ mono: true })}
/>
</CPRow>
)}
{(f.widgetType === "entity" || f.widgetType === "select") && (
<>
<CPRow label="참조 테이블">
<input
type="text"
value={f.referenceTable ?? ""}
onChange={(e) =>
update(idx, {
referenceTable: e.target.value || undefined,
})
}
placeholder="예: tb_customer"
style={inputStyle({ mono: true })}
/>
</CPRow>
<CPRow label="참조 컬럼 (값)">
<input
type="text"
value={f.referenceColumn ?? ""}
onChange={(e) =>
update(idx, {
referenceColumn: e.target.value || undefined,
})
}
placeholder="예: customer_code"
style={inputStyle({ mono: true })}
/>
</CPRow>
<CPRow label="표시 컬럼 (라벨)">
<input
type="text"
value={f.displayColumn ?? ""}
onChange={(e) =>
update(idx, {
displayColumn: e.target.value || undefined,
})
}
placeholder="예: customer_name"
style={inputStyle({ mono: true })}
/>
</CPRow>
</>
)}
</div>
))}
</div>
)}
<AddRowButton label="검색 필터 추가" onClick={add} />
</>
);
}
// ──── LinkedFilterList (linkedFilters[]) ────
function LinkedFilterList({
items,
columnOptions,
onChange,
}: {
items: NonNullable<TableConfig["linkedFilters"]>;
columnOptions: ColOpt[];
onChange: (next: NonNullable<TableConfig["linkedFilters"]>) => void;
}) {
const update = (
idx: number,
p: Partial<(typeof items)[number]>,
) => onChange(items.map((f, i) => (i === idx ? { ...f, ...p } : f)));
const remove = (idx: number) => onChange(items.filter((_, i) => i !== idx));
const add = () =>
onChange([
...items,
{
sourceComponentId: "",
targetColumn: columnOptions[0]?.value || "",
operator: "equals",
enabled: true,
},
]);
return (
<>
{items.length === 0 ? (
<Hint> . [+ ] row .</Hint>
) : (
<div
style={{
border: "1px solid var(--cp-border-subtle)",
borderRadius: 5,
overflow: "hidden",
background: "var(--cp-bg-subtle)",
}}
>
{items.map((f, idx) => (
<div
key={`${f.sourceComponentId}-${idx}`}
style={{
padding: "6px 8px",
borderBottom:
idx === items.length - 1
? "none"
: "1px solid var(--cp-border-subtle)",
display: "flex",
flexDirection: "column",
gap: 4,
}}
>
<div
style={{
display: "grid",
gridTemplateColumns: "auto 1fr 22px",
gap: 4,
alignItems: "center",
}}
>
<CPSwitch
value={f.enabled !== false}
onChange={(v) => update(idx, { enabled: v })}
/>
<input
type="text"
value={f.sourceComponentId}
onChange={(e) =>
update(idx, { sourceComponentId: e.target.value })
}
placeholder="source 컴포넌트 id"
style={inputStyle({ mono: true })}
/>
<RemoveRowButton onClick={() => remove(idx)} />
</div>
<CPRow label="source 필드" help="비워두면 source 의 value 기본 사용">
<input
type="text"
value={f.sourceField ?? ""}
onChange={(e) =>
update(idx, { sourceField: e.target.value || undefined })
}
placeholder="value"
style={inputStyle({ mono: true })}
/>
</CPRow>
<CPRow label="대상 컬럼">
<CPSelect
value={f.targetColumn}
onChange={(v) => update(idx, { targetColumn: v })}
sortable={false}
options={[{ value: "", label: "(컬럼)" }, ...columnOptions]}
/>
</CPRow>
<CPRow label="연산자">
<CPSegment
value={f.operator || "equals"}
onChange={(v) =>
update(idx, {
operator: v as "equals" | "contains" | "in",
})
}
options={[
{ value: "equals", label: "=" },
{ value: "contains", label: "포함" },
{ value: "in", label: "IN" },
]}
/>
</CPRow>
</div>
))}
</div>
)}
<AddRowButton label="연결 필터 추가" onClick={add} />
</>
);
}
// ──── DataFilterList (dataFilter.filters[]) ────
const DATA_FILTER_OPERATOR_OPTIONS: ColOpt[] = [
{ value: "equals", label: "= equals" },
{ value: "not_equals", label: "≠ not_equals" },
{ value: "in", label: "IN" },
{ value: "not_in", label: "NOT IN" },
{ value: "contains", label: "contains" },
{ value: "starts_with", label: "starts_with" },
{ value: "ends_with", label: "ends_with" },
{ value: "is_null", label: "is_null" },
{ value: "is_not_null", label: "is_not_null" },
{ value: "greater_than", label: ">" },
{ value: "less_than", label: "<" },
{ value: "greater_than_or_equal", label: "≥" },
{ value: "less_than_or_equal", label: "≤" },
{ value: "between", label: "between" },
{ value: "date_range_contains", label: "date_range_contains" },
];
function DataFilterList({
filters,
columnOptions,
onChange,
}: {
filters: NonNullable<TableConfig["dataFilter"]>["filters"];
columnOptions: ColOpt[];
onChange: (next: NonNullable<TableConfig["dataFilter"]>["filters"]) => void;
}) {
const update = (idx: number, p: Partial<(typeof filters)[number]>) =>
onChange(filters.map((f, i) => (i === idx ? { ...f, ...p } : f)));
const remove = (idx: number) => onChange(filters.filter((_, i) => i !== idx));
const add = () =>
onChange([
...filters,
{
id: `filter-${Date.now()}`,
column_name: columnOptions[0]?.value || "",
operator: "equals",
value: "",
value_type: "static",
},
]);
const onValueChange = (
idx: number,
raw: string,
operator: string,
) => {
if (operator === "in" || operator === "not_in") {
const arr = raw
.split(",")
.map((s) => s.trim())
.filter(Boolean);
update(idx, { value: arr });
} else {
update(idx, { value: raw });
}
};
const onOperatorChange = (
idx: number,
operator: (typeof filters)[number]["operator"],
currentValue: (typeof filters)[number]["value"],
) => {
const currentText = Array.isArray(currentValue)
? currentValue.join(", ")
: currentValue ?? "";
const nextValue =
operator === "in" || operator === "not_in"
? currentText
.split(",")
.map((s) => s.trim())
.filter(Boolean)
: currentText;
update(idx, { operator, value: nextValue });
};
return (
<>
{filters.length === 0 ? (
<Hint> row . [+ ] row .</Hint>
) : (
<div
style={{
border: "1px solid var(--cp-border-subtle)",
borderRadius: 5,
overflow: "hidden",
background: "var(--cp-bg-subtle)",
}}
>
{filters.map((f, idx) => {
const valueAsText = Array.isArray(f.value)
? f.value.join(", ")
: f.value ?? "";
const isNullOp = f.operator === "is_null" || f.operator === "is_not_null";
const isRangeOp = f.operator === "date_range_contains";
return (
<div
key={f.id || idx}
style={{
padding: "6px 8px",
borderBottom:
idx === filters.length - 1
? "none"
: "1px solid var(--cp-border-subtle)",
display: "flex",
flexDirection: "column",
gap: 4,
}}
>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr 22px",
gap: 4,
alignItems: "center",
}}
>
<CPSelect
value={f.column_name}
onChange={(v) => update(idx, { column_name: v })}
sortable={false}
options={[{ value: "", label: "(컬럼)" }, ...columnOptions]}
/>
<CPSelect
value={f.operator}
onChange={(v) =>
onOperatorChange(
idx,
v as (typeof f)["operator"],
f.value,
)
}
sortable={false}
options={DATA_FILTER_OPERATOR_OPTIONS}
/>
<RemoveRowButton onClick={() => remove(idx)} />
</div>
<CPRow label="값 타입">
<CPSegment
value={f.value_type}
onChange={(v) =>
update(idx, {
value_type: v as
| "static"
| "category"
| "code"
| "dynamic",
})
}
options={[
{ value: "static", label: "정적" },
{ value: "category", label: "카테고리" },
{ value: "code", label: "공통코드" },
{ value: "dynamic", label: "동적" },
]}
/>
</CPRow>
{!isNullOp && (
<CPRow
label="값"
help={
f.operator === "in" || f.operator === "not_in"
? "콤마 구분 (배열로 저장)"
: undefined
}
>
<input
type="text"
value={valueAsText}
onChange={(e) =>
onValueChange(idx, e.target.value, f.operator)
}
placeholder={
f.operator === "in" || f.operator === "not_in"
? "a, b, c"
: ""
}
style={inputStyle()}
/>
</CPRow>
)}
{isRangeOp && (
<>
<CPRow label="시작일 컬럼">
<CPSelect
value={f.range_config?.start_column ?? ""}
onChange={(v) =>
update(idx, {
range_config: {
start_column: v,
end_column: f.range_config?.end_column || "",
},
})
}
sortable={false}
options={[
{ value: "", label: "(컬럼)" },
...columnOptions,
]}
/>
</CPRow>
<CPRow label="종료일 컬럼">
<CPSelect
value={f.range_config?.end_column ?? ""}
onChange={(v) =>
update(idx, {
range_config: {
start_column: f.range_config?.start_column || "",
end_column: v,
},
})
}
sortable={false}
options={[
{ value: "", label: "(컬럼)" },
...columnOptions,
]}
/>
</CPRow>
</>
)}
</div>
);
})}
</div>
)}
<AddRowButton label="컬럼 필터 추가" onClick={add} />
</>
);
}
// ──── ActionItemList (Phase C.4 — actions.actions[]) ────
const ACTION_TYPE_OPTIONS: { value: TableActionType; label: string }[] = [
{ value: "view", label: "보기" },
{ value: "edit", label: "수정" },
{ value: "delete", label: "삭제" },
{ value: "custom", label: "커스텀" },
];
function ActionItemList({
items,
onChange,
}: {
items: TableActionItemConfig[];
onChange: (next: TableActionItemConfig[]) => void;
}) {
const update = (idx: number, p: Partial<TableActionItemConfig>) =>
onChange(items.map((it, i) => (i === idx ? { ...it, ...p } : it)));
const remove = (idx: number) => onChange(items.filter((_, i) => i !== idx));
const add = () =>
onChange([
...items,
{ type: "view", label: "보기" },
]);
return (
<>
{items.length === 0 ? (
<Hint> . [+ ] row .</Hint>
) : (
<div
style={{
border: "1px solid var(--cp-border-subtle)",
borderRadius: 5,
overflow: "hidden",
background: "var(--cp-bg-subtle)",
}}
>
{items.map((it, idx) => (
<div
key={`${it.type}-${idx}`}
style={{
padding: "6px 8px",
borderBottom:
idx === items.length - 1
? "none"
: "1px solid var(--cp-border-subtle)",
display: "flex",
flexDirection: "column",
gap: 4,
}}
>
<div
style={{
display: "grid",
gridTemplateColumns: "auto 1fr 22px",
gap: 4,
alignItems: "center",
}}
>
<CPSegment
value={it.type}
onChange={(v) =>
update(idx, { type: v as TableActionType })
}
options={ACTION_TYPE_OPTIONS}
/>
<input
type="text"
value={it.label}
onChange={(e) => update(idx, { label: e.target.value })}
placeholder="라벨"
style={inputStyle()}
/>
<RemoveRowButton onClick={() => remove(idx)} />
</div>
<CPRow label="아이콘" help="lucide-react 아이콘명 (예: Eye / Pencil / Trash2)">
<input
type="text"
value={it.icon ?? ""}
onChange={(e) =>
update(idx, { icon: e.target.value || undefined })
}
placeholder="Eye"
style={inputStyle({ mono: true })}
/>
</CPRow>
<CPRow label="색상" help="primary / destructive / muted 등 의미 토큰 또는 hsl 값">
<input
type="text"
value={it.color ?? ""}
onChange={(e) =>
update(idx, { color: e.target.value || undefined })
}
placeholder="primary"
style={inputStyle()}
/>
</CPRow>
<CPRow label="확인 메시지" help="delete 등 위험 액션 시 confirm 다이얼로그 텍스트 (D.4)">
<input
type="text"
value={it.confirmMessage ?? ""}
onChange={(e) =>
update(idx, {
confirmMessage: e.target.value || undefined,
})
}
placeholder="예: 정말 삭제하시겠습니까?"
style={inputStyle()}
/>
</CPRow>
<CPRow label="대상 화면" help="view/edit 의 navigation 대상 (D.4)">
<input
type="text"
value={it.targetScreen ?? ""}
onChange={(e) =>
update(idx, {
targetScreen: e.target.value || undefined,
})
}
placeholder="예: /screen/orderDetail"
style={inputStyle({ mono: true })}
/>
</CPRow>
</div>
))}
</div>
)}
<AddRowButton label="행 액션 추가" onClick={add} />
</>
);
}
export default InvTableConfigPanel;