2329 lines
81 KiB
TypeScript
2329 lines
81 KiB
TypeScript
"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;
|