"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 = ({ config, onChange, selectedComponent, tables, tableColumns, screenTableName, onTableChange, }) => { const current: TableConfig = (config as TableConfig) || (selectedComponent?.config as TableConfig) || {}; const patch = useCallback( (p: Partial) => onChange?.({ ...current, ...p }), [current, onChange], ); const columns: TableColumn[] = current.columns ?? []; // 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([]); const [loadingConnectedColumns, setLoadingConnectedColumns] = useState(false); useEffect(() => { let cancelled = false; if (!connectedTable) { setConnectedTableColumns([]); setLoadingConnectedColumns(false); return () => { cancelled = true; }; } if ( connectedTable === screenTableName && Array.isArray(tableColumns) && tableColumns.length > 0 ) { setConnectedTableColumns(tableColumns); setLoadingConnectedColumns(false); return () => { cancelled = true; }; } setLoadingConnectedColumns(true); (async () => { try { const { tableTypeApi } = await import("@/lib/api/screen"); const cols = await tableTypeApi.getColumns(connectedTable, true); if (!cancelled) setConnectedTableColumns(Array.isArray(cols) ? cols : []); } catch { if (!cancelled) setConnectedTableColumns([]); } finally { if (!cancelled) setLoadingConnectedColumns(false); } })(); return () => { cancelled = true; }; }, [connectedTable, screenTableName, tableColumns]); const effectiveTableColumns = useMemo(() => { if ( connectedTable === screenTableName && Array.isArray(tableColumns) && tableColumns.length > 0 ) { return tableColumns; } return connectedTableColumns; }, [connectedTable, screenTableName, tableColumns, connectedTableColumns]); const autoLoadColumns = useCallback(() => { if (!effectiveTableColumns?.length) return; const newCols: TableColumn[] = effectiveTableColumns.map((col: any) => ({ key: col.columnName || col.column_name, label: col.columnLabel || col.column_label || col.displayName || col.columnName || col.column_name, width: undefined, align: "left" as const, sortable: true, visible: true, })); patch({ columns: newCols }); }, [effectiveTableColumns, patch]); const updateColumn = (idx: number, col: Partial) => { const next = columns.map((c, i) => (i === idx ? { ...c, ...col } : c)); patch({ columns: next }); }; const addColumn = () => { patch({ columns: [ ...columns, { key: `col${columns.length + 1}`, label: `컬럼 ${columns.length + 1}` }, ], }); }; const removeColumn = (idx: number) => { patch({ columns: columns.filter((_, i) => i !== idx) }); }; const displayMode = current.displayMode || "table"; // ─── 사용 가능한 컬럼 옵션 (Phase C.3 — 필터 row 의 column select 용) ─── // 1) canonical columns 우선 // 2) connected DB metadata fallback const availableColumnOptions = useMemo(() => { const seen = new Set(); 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>) => patch({ filter: { enabled: false, filters: [], ...current.filter, ...next } as any }); const patchExcludeFilter = (next: Partial>) => patch({ excludeFilter: { enabled: false, referenceTable: "", referenceColumn: "", sourceColumn: "", ...current.excludeFilter, ...next, } as any, }); const patchDataFilter = (next: Partial>) => 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>, ) => patch({ actions: { showActions: false, actions: [], bulkActions: false, bulkActionList: [], ...current.actions, ...next, } as any, }); return (
{/* ── ① 테이블 연결 ─────────────────────────── */} { onTableChange?.(v); // canonical 정규 키만 set. legacy alias (tableName) 는 유지하지 않는다 — 새 ConfigPanel // 에서 편집된 결과는 selectedTable 단일 키로 저장. patch({ selectedTable: v, columns: [] } as any); }} options={tableOptions} desc="DB 테이블 매핑" > {connectedTable && ( {loadingConnectedColumns ? "컬럼 정보를 불러오는 중..." : effectiveTableColumns.length > 0 ? `컬럼 ${effectiveTableColumns.length}개 준비됨` : "불러온 컬럼 정보가 없습니다"} )} {connectedTable && effectiveTableColumns.length > 0 && ( )} {/* ── 데이터 소스 옵션 (Phase C.1, 2026-05-20) ─────────────── customTableName / useCustomTable / isReadOnly / autoLoad 를 canonical TableConfig 에 흡수. */} patch({ useCustomTable: next })} /> {current.useCustomTable && ( patch({ customTableName: v })} placeholder="예: tb_customer" /> )} patch({ isReadOnly: next })} /> patch({ autoLoad: next })} /> {/* ── ② 표시 모드 ─────────────────────────── */} patch({ displayMode: v as TableConfig["displayMode"] })} options={[ { value: "table", label: "기본", preview: , desc: "일반 테이블" }, { value: "split", label: "분할", preview: , desc: "좌우 분할" }, { value: "grouped", label: "그룹", preview: , desc: "그룹핑" }, { value: "pivot", label: "피벗", preview: , desc: "피벗 그리드" }, { value: "card", label: "카드", preview: , desc: "카드 리스트" }, ]} /> {/* ── ③ 행 선택 + 높이 ─────────────────────────── */} patch({ selectionMode: v as TableConfig["selectionMode"] })} options={[ { value: "none", label: "없음" }, { value: "single", label: "단일" }, { value: "multiple", label: "복수" }, ]} /> patch({ rowHeight: v as TableConfig["rowHeight"] })} options={[ { value: "compact", label: "좁게" }, { value: "normal", label: "기본" }, { value: "relaxed", label: "넓게" }, ]} /> {/* ── ④ 모드별 설정 (조건부) ─────────────────────────── */} {displayMode === "split" && ( patch({ splitRatio: v ?? 0.5 })} min={0.1} max={0.9} step={0.05} /> )} {displayMode === "grouped" && ( patch({ groupBy: v || undefined })} searchable={false} > {columns.map((c) => ( ))} {columns.length === 0 && ( 컬럼을 먼저 자동 로드 또는 추가하세요. )} )} {displayMode === "pivot" && ( {/* 피벗 필드 배치는 PivotView 본체의 FieldPanel/FieldChooser가 담당한다. ConfigPanel은 메타 토글만 관리한다. */} { 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; } }} /> )} {displayMode === "card" && ( patch({ cardsPerRow: v ?? 3 })} min={1} max={10} /> patch({ cardSpacing: v ?? 12 })} min={0} max={64} /> patch({ cardStyle: { ...current.cardStyle, showTitle: v } })} /> patch({ cardStyle: { ...current.cardStyle, showSubtitle: v } })} /> patch({ cardStyle: { ...current.cardStyle, showDescription: v } })} /> patch({ cardStyle: { ...current.cardStyle, showImage: v } })} /> {(current.cardStyle?.showImage ?? true) && ( <> patch({ cardStyle: { ...current.cardStyle, imagePosition: v as any } })} options={[ { value: "top", label: "상단" }, { value: "left", label: "좌측" }, { value: "right", label: "우측" }, ]} /> patch({ cardStyle: { ...current.cardStyle, imageSize: v as any } })} options={[ { value: "small", label: "작게" }, { value: "medium", label: "보통" }, { value: "large", label: "크게" }, ]} /> )} {columns.length === 0 ? ( 컬럼을 먼저 자동 로드 또는 추가하세요. ) : ( <> patch({ cardColumnMapping: { ...current.cardColumnMapping, titleColumn: v || undefined, }, }) } > {columns.map((c) => ( ))} patch({ cardColumnMapping: { ...current.cardColumnMapping, subtitleColumn: v || undefined, }, }) } > {columns.map((c) => ( ))} patch({ cardColumnMapping: { ...current.cardColumnMapping, descriptionColumn: v || undefined, }, }) } > {columns.map((c) => ( ))} patch({ cardColumnMapping: { ...current.cardColumnMapping, imageColumn: v || undefined, }, }) } > {columns.map((c) => ( ))} )} patch({ cardStyle: { ...current.cardStyle, showActions: v } })} /> {current.cardStyle?.showActions && ( <> patch({ cardStyle: { ...current.cardStyle, showViewButton: v } }) } /> patch({ cardStyle: { ...current.cardStyle, showEditButton: v } }) } /> patch({ cardStyle: { ...current.cardStyle, showDeleteButton: v } }) } /> )} )} {/* ── ⑤ 컬럼 ─────────────────────────── */}
{columns.length === 0 ? ( {connectedTable ? loadingConnectedColumns ? "컬럼 정보를 불러오는 중입니다." : effectiveTableColumns.length > 0 ? "위 [자동 로드] 버튼으로 한 번에 가져올 수 있습니다." : "연결된 테이블에서 컬럼 정보를 찾지 못했습니다." : "테이블을 연결하면 자동 로드할 수 있습니다."} ) : (
{columns.map((col, idx) => ( updateColumn(idx, p)} onRemove={() => removeColumn(idx)} /> ))}
)}
{/* ── ⑥ 페이지네이션 (Phase C.5) ─────────────────────────── */} patch({ pagination: { ...current.pagination, enabled: v } }) } /> patch({ pagination: { ...current.pagination, pageSize: v ?? 20 } }) } min={1} max={1000} /> { 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%", }} /> patch({ pagination: { ...current.pagination, showSizeSelector: v }, }) } /> patch({ pagination: { ...current.pagination, showPageInfo: v }, }) } /> {/* ── ⑦ 데이터 동작 (Phase C.5) ─────────────────────────── */} 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 })), ]} /> {current.defaultSort?.columnName && ( patch({ defaultSort: { columnName: current.defaultSort!.columnName, direction: v as "asc" | "desc", }, }) } options={[ { value: "asc", label: "오름" }, { value: "desc", label: "내림" }, ]} /> )} patch({ refreshInterval: v && v > 0 ? v : undefined, }) } placeholder="0" min={0} /> {/* ── ⑧ 스타일 (Phase C.5) ─────────────────────────── */} 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: "미니멀" }, ]} /> patch({ tableStyle: { ...current.tableStyle, headerStyle: v as "default" | "dark" | "light", }, }) } sortable={false} options={[ { value: "default", label: "기본" }, { value: "dark", label: "다크" }, { value: "light", label: "라이트" }, ]} /> patch({ tableStyle: { ...current.tableStyle, borderStyle: v as "none" | "light" | "heavy", }, }) } sortable={false} options={[ { value: "none", label: "없음" }, { value: "light", label: "얇음" }, { value: "heavy", label: "두꺼움" }, ]} /> patch({ tableStyle: { ...current.tableStyle, alternateRows: v }, striped: v, }) } /> patch({ tableStyle: { ...current.tableStyle, hoverEffect: v }, hoverable: v, }) } /> {/* ── ⑨ 툴바 버튼 (Phase C.5 — 실제 버튼 동작은 D.6) ─────────────────────────── */} ⑨ 의 8개 옵션 중 새로고침 / 엑셀은 즉시 노출. 나머지 6개 (편집모드 / PDF / 복사 / 검색 / 필터 / 페이지네이션 새로고침) 는 D.6 에서 실제 버튼 렌더 + 동작 wiring. patch({ toolbar: { ...current.toolbar, showRefresh: v }, showRefresh: v, }) } /> patch({ toolbar: { ...current.toolbar, showExcel: v }, showExcel: v, }) } /> patch({ toolbar: { ...current.toolbar, showEditMode: v } }) } /> patch({ toolbar: { ...current.toolbar, showPdf: v } }) } /> patch({ toolbar: { ...current.toolbar, showCopy: v } }) } /> patch({ toolbar: { ...current.toolbar, showSearch: v } }) } /> patch({ toolbar: { ...current.toolbar, showFilter: v } }) } /> patch({ toolbar: { ...current.toolbar, showPaginationRefresh: v }, }) } /> {/* ── ⑩ 필터 (Phase C.3 — runtime 적용은 D.2) ─────────────────────────── */} C.3 는 config 보존 + 편집 UI 까지. 실제 검색 위젯 렌더 / source 컴포넌트 구독 / 제외 sub-query / DataFilter 적용은 D.2 에서. {/* ── 검색 필터 위젯 (filter) ── */} 검색 필터 위젯 patchFilter({ enabled: v })} /> patchFilter({ bottomSpacing: v })} placeholder="40" min={0} /> patchFilter({ filters })} /> {/* ── 연결 필터 (linkedFilters) ── */} 연결 필터 다른 컴포넌트 (셀렉트박스 등) 값 변경 시 본 테이블 컬럼을 자동 필터. patch({ linkedFilters })} /> {/* ── 제외 필터 (excludeFilter) ── */} 제외 필터 참조 테이블의 row 를 본 테이블 결과에서 제외 (예: 이미 등록된 품목을 품목 선택 모달에서 제외). patchExcludeFilter({ enabled: v })} /> patchExcludeFilter({ referenceTable: e.target.value })} placeholder="예: customer_item_mapping" style={inputStyle()} /> patchExcludeFilter({ referenceColumn: e.target.value })} placeholder="예: item_id" style={inputStyle()} /> patchExcludeFilter({ sourceColumn: v })} sortable={false} options={[ { value: "", label: "(선택)" }, ...availableColumnOptions, ]} /> patchExcludeFilter({ filterColumn: e.target.value || undefined }) } placeholder="예: customer_id" style={inputStyle()} /> patchExcludeFilter({ filterValueSource: v as "url" | "formData" | "parentData", }) } options={[ { value: "url", label: "URL" }, { value: "formData", label: "폼" }, { value: "parentData", label: "상위" }, ]} /> patchExcludeFilter({ filterValueField: e.target.value || undefined }) } placeholder="예: customer_code" style={inputStyle()} /> {/* ── 데이터 필터 (dataFilter) ── */} 데이터 필터 (정적) 14 operator (equals / in / between / date_range_contains 등) 기반 row 필터. patchDataFilter({ enabled: v })} /> patchDataFilter({ match_type: v as "all" | "any" }) } options={[ { value: "all", label: "모두" }, { value: "any", label: "하나 이상" }, ]} /> patchDataFilter({ filters })} /> {/* ── ⑪ 액션 (Phase C.4 — runtime 렌더/실행은 D.4) ─────────────────────────── */} Table-level row/bulk 액션 묶음. C.4 는 config 편집까지. 실제 액션 컬럼 렌더 / click 동작 (navigation / 모달 / 삭제 API / custom 핸들러) 은 D.4. 카드 모드의 셀 내부 버튼 (cardStyle.showActions 등) 과는 별개 layer. {/* ── 행 액션 (actions.actions[]) ── */} 행 액션 patchActions({ showActions: v })} /> patchActions({ actions })} /> {/* ── 일괄 액션 (actions.bulkActions / bulkActionList) ── */} 일괄 액션 patchActions({ bulkActions: v })} /> { const arr = e.target.value .split(",") .map((s) => s.trim()) .filter(Boolean); patchActions({ bulkActionList: arr }); }} placeholder="예: delete, export" style={inputStyle()} /> {/* ── ▾ 동작 옵션 ─────────────────────────── */} { 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); }} />
); }; InvTableConfigPanel.displayName = "InvTableConfigPanel"; // ─────────────────────────────────────────────────────── // ColumnEditRow — 컬럼 한 줄 편집 (dense) // ─────────────────────────────────────────────────────── function ColumnEditRow({ index, col, isLast, onChange, onRemove, }: { index: number; col: TableColumn; isLast: boolean; onChange: (p: Partial) => void; onRemove: () => void; }) { const [hover, setHover] = useState(false); const [expanded, setExpanded] = useState(false); return (
setHover(true)} onMouseLeave={() => setHover(false)} style={{ borderBottom: isLast ? "none" : "1px solid var(--cp-border-subtle)", background: hover ? "var(--cp-surface-hover, var(--cp-surface))" : "transparent", transition: "background .12s ease", fontFamily: "var(--v5-font-sans)", }} > {/* 한 줄: # + 라벨 + key + 펼침 화살표 + 삭제 */}
onChange({ label: e.target.value })} placeholder="라벨" style={inputStyle()} /> onChange({ key: e.target.value })} placeholder="컬럼 key" style={inputStyle({ mono: true })} /> setExpanded((x) => !x)} />
{/* 펼친 옵션 (Phase C.2 풀 옵션) */} {expanded && (
{/* 너비 + 정렬 */}
onChange({ width: v })} placeholder="auto" min={0} /> onChange({ align: v as TableColumn["align"] })} options={[ { value: "left", label: }, { value: "center", label: }, { value: "right", label: }, ]} />
{/* 고정 + 입력 타입 */}
onChange({ fixed: v === "left" ? "left" : v === "right" ? "right" : false, }) } options={[ { value: "left", label: "좌" }, { value: "none", label: "—" }, { value: "right", label: "우" }, ]} /> 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: "코드" }, ]} />
{/* 동작 — sortable / searchable / editable */} onChange({ sortable: v })} /> onChange({ searchable: v })} /> onChange({ editable: v })} /> {/* 표시 — visible / hidden */} onChange({ visible: v })} /> onChange({ hidden: v })} /> {/* 포맷 + 천단위 */} onChange({ format: e.target.value || undefined })} placeholder="예: ###,### / YYYY-MM-DD" style={inputStyle()} /> {(col.inputType === "number" || col.format === "number" || col.format === "currency") && ( onChange({ thousandSeparator: v })} /> )}
)}
); } function inputStyle({ mono = false }: { mono?: boolean } = {}): React.CSSProperties { return { height: 22, padding: "0 6px", fontSize: 11, fontFamily: mono ? "var(--v5-font-mono)" : "var(--v5-font-sans)", background: "var(--cp-surface)", border: "1px solid var(--cp-border)", borderRadius: 3, color: "var(--cp-text)", outline: "none", minWidth: 0, }; } // ─────────────────────────────────────────────────────── // Phase C.3 (2026-05-20) — 필터 sub-editor helpers // ─────────────────────────────────────────────────────── type ColOpt = { value: string; label: string }; function SubSectionHeading({ children }: { children: React.ReactNode }) { return (
{children}
); } function AddRowButton({ label, onClick, }: { label: string; onClick: () => void; }) { return (
); } function RemoveRowButton({ onClick }: { onClick: () => void }) { return ( ); } 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["filters"]; columnOptions: ColOpt[]; onChange: (next: NonNullable["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 ? ( 필터 위젯이 없습니다. 아래 [+ 검색 필터 추가] 로 새 row 를 만드세요. ) : (
{filters.map((f, idx) => (
update(idx, { columnName: v })} sortable={false} options={[{ value: "", label: "(컬럼)" }, ...columnOptions]} /> update(idx, { widgetType: v })} sortable={false} options={WIDGET_TYPE_OPTIONS} /> update(idx, { label: e.target.value })} placeholder="라벨" style={inputStyle()} /> update(idx, { gridColumns: v ?? 3 })} min={1} max={12} /> remove(idx)} />
{f.widgetType === "number" && ( update(idx, { numberFilterMode: v as "exact" | "range", }) } options={[ { value: "exact", label: "단일" }, { value: "range", label: "범위" }, ]} /> )} {f.widgetType === "code" && ( update(idx, { codeInfo: e.target.value || undefined }) } placeholder="예: ORD_STATUS" style={inputStyle({ mono: true })} /> )} {(f.widgetType === "entity" || f.widgetType === "select") && ( <> update(idx, { referenceTable: e.target.value || undefined, }) } placeholder="예: tb_customer" style={inputStyle({ mono: true })} /> update(idx, { referenceColumn: e.target.value || undefined, }) } placeholder="예: customer_code" style={inputStyle({ mono: true })} /> update(idx, { displayColumn: e.target.value || undefined, }) } placeholder="예: customer_name" style={inputStyle({ mono: true })} /> )}
))}
)} ); } // ──── LinkedFilterList (linkedFilters[]) ──── function LinkedFilterList({ items, columnOptions, onChange, }: { items: NonNullable; columnOptions: ColOpt[]; onChange: (next: NonNullable) => 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 ? ( 연결된 필터가 없습니다. 아래 [+ 연결 필터 추가] 로 새 row 를 만드세요. ) : (
{items.map((f, idx) => (
update(idx, { enabled: v })} /> update(idx, { sourceComponentId: e.target.value }) } placeholder="source 컴포넌트 id" style={inputStyle({ mono: true })} /> remove(idx)} />
update(idx, { sourceField: e.target.value || undefined }) } placeholder="value" style={inputStyle({ mono: true })} /> update(idx, { targetColumn: v })} sortable={false} options={[{ value: "", label: "(컬럼)" }, ...columnOptions]} /> update(idx, { operator: v as "equals" | "contains" | "in", }) } options={[ { value: "equals", label: "=" }, { value: "contains", label: "포함" }, { value: "in", label: "IN" }, ]} />
))}
)} ); } // ──── 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["filters"]; columnOptions: ColOpt[]; onChange: (next: NonNullable["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 ? ( 데이터 필터 row 가 없습니다. 아래 [+ 컬럼 필터 추가] 로 새 row 를 만드세요. ) : (
{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 (
update(idx, { column_name: v })} sortable={false} options={[{ value: "", label: "(컬럼)" }, ...columnOptions]} /> onOperatorChange( idx, v as (typeof f)["operator"], f.value, ) } sortable={false} options={DATA_FILTER_OPERATOR_OPTIONS} /> remove(idx)} />
update(idx, { value_type: v as | "static" | "category" | "code" | "dynamic", }) } options={[ { value: "static", label: "정적" }, { value: "category", label: "카테고리" }, { value: "code", label: "공통코드" }, { value: "dynamic", label: "동적" }, ]} /> {!isNullOp && ( onValueChange(idx, e.target.value, f.operator) } placeholder={ f.operator === "in" || f.operator === "not_in" ? "a, b, c" : "" } style={inputStyle()} /> )} {isRangeOp && ( <> update(idx, { range_config: { start_column: v, end_column: f.range_config?.end_column || "", }, }) } sortable={false} options={[ { value: "", label: "(컬럼)" }, ...columnOptions, ]} /> update(idx, { range_config: { start_column: f.range_config?.start_column || "", end_column: v, }, }) } sortable={false} options={[ { value: "", label: "(컬럼)" }, ...columnOptions, ]} /> )}
); })}
)} ); } // ──── 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) => 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 ? ( 행 액션이 없습니다. 아래 [+ 행 액션 추가] 로 새 row 를 만드세요. ) : (
{items.map((it, idx) => (
update(idx, { type: v as TableActionType }) } options={ACTION_TYPE_OPTIONS} /> update(idx, { label: e.target.value })} placeholder="라벨" style={inputStyle()} /> remove(idx)} />
update(idx, { icon: e.target.value || undefined }) } placeholder="Eye" style={inputStyle({ mono: true })} /> update(idx, { color: e.target.value || undefined }) } placeholder="primary" style={inputStyle()} /> update(idx, { confirmMessage: e.target.value || undefined, }) } placeholder="예: 정말 삭제하시겠습니까?" style={inputStyle()} /> update(idx, { targetScreen: e.target.value || undefined, }) } placeholder="예: /screen/orderDetail" style={inputStyle({ mono: true })} />
))}
)} ); } export default InvTableConfigPanel;