"use client"; /** * V2Select 설정 패널 * 토스식 단계별 UX: 소스 카드 선택 -> 소스별 설정 -> 고급 설정(접힘) */ import React, { useState, useEffect, useCallback, useMemo } from "react"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; import { Button } from "@/components/ui/button"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { Separator } from "@/components/ui/separator"; import { List, Database, FolderTree, Settings, ChevronDown, Plus, Trash2, Loader2, Filter } from "lucide-react"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; import type { V2SelectFilter } from "@/types/v2-components"; interface ColumnOption { columnName: string; columnLabel: string; } interface CategoryValueOption { valueCode: string; valueLabel: string; } const OPERATOR_OPTIONS = [ { value: "=", label: "같음 (=)" }, { value: "!=", label: "다름 (!=)" }, { value: ">", label: "초과 (>)" }, { value: "<", label: "미만 (<)" }, { value: ">=", label: "이상 (>=)" }, { value: "<=", label: "이하 (<=)" }, { value: "in", label: "포함 (IN)" }, { value: "notIn", label: "미포함 (NOT IN)" }, { value: "like", label: "유사 (LIKE)" }, { value: "isNull", label: "NULL" }, { value: "isNotNull", label: "NOT NULL" }, ] as const; const VALUE_TYPE_OPTIONS = [ { value: "static", label: "고정값" }, { value: "field", label: "폼 필드 참조" }, { value: "user", label: "로그인 사용자" }, ] as const; const USER_FIELD_OPTIONS = [ { value: "companyCode", label: "회사코드" }, { value: "userId", label: "사용자ID" }, { value: "deptCode", label: "부서코드" }, { value: "userName", label: "사용자명" }, ] as const; // ─── 데이터 소스 카드 정의 ─── const SOURCE_CARDS = [ { value: "static", icon: List, title: "직접 입력", description: "옵션을 직접 추가해요", }, { value: "category", icon: FolderTree, title: "카테고리", description: "등록된 선택지를 사용해요", }, { value: "entity", icon: Database, title: "테이블 참조", description: "다른 테이블에서 가져와요", entityOnly: true, }, ] as const; /** * 필터 조건 설정 서브 컴포넌트 */ const FilterConditionsSection: React.FC<{ filters: V2SelectFilter[]; columns: ColumnOption[]; loadingColumns: boolean; targetTable: string; onFiltersChange: (filters: V2SelectFilter[]) => void; }> = ({ filters, columns, loadingColumns, targetTable, onFiltersChange }) => { const addFilter = () => { onFiltersChange([ ...filters, { column: "", operator: "=", valueType: "static", value: "" }, ]); }; const updateFilter = (index: number, patch: Partial) => { const updated = [...filters]; updated[index] = { ...updated[index], ...patch }; if (patch.valueType) { if (patch.valueType === "static") { updated[index].fieldRef = undefined; updated[index].userField = undefined; } else if (patch.valueType === "field") { updated[index].value = undefined; updated[index].userField = undefined; } else if (patch.valueType === "user") { updated[index].value = undefined; updated[index].fieldRef = undefined; } } if (patch.operator === "isNull" || patch.operator === "isNotNull") { updated[index].value = undefined; updated[index].fieldRef = undefined; updated[index].userField = undefined; updated[index].valueType = "static"; } onFiltersChange(updated); }; const removeFilter = (index: number) => { onFiltersChange(filters.filter((_, i) => i !== index)); }; const needsValue = (op: string) => op !== "isNull" && op !== "isNotNull"; return (
데이터 필터

{targetTable} 테이블에서 옵션을 불러올 때 적용할 조건

{loadingColumns && (
컬럼 목록 로딩 중...
)} {filters.length === 0 && (

필터 조건이 없습니다

)}
{filters.map((filter, index) => (
{needsValue(filter.operator) && (
{(filter.valueType || "static") === "static" && ( updateFilter(index, { value: e.target.value })} placeholder={filter.operator === "in" || filter.operator === "notIn" ? "값1, 값2, ..." : "값 입력"} className="h-7 flex-1 text-[11px]" /> )} {filter.valueType === "field" && ( updateFilter(index, { fieldRef: e.target.value })} placeholder="참조할 필드명 (columnName)" className="h-7 flex-1 text-[11px]" /> )} {filter.valueType === "user" && ( )}
)}
))}
); }; interface V2SelectConfigPanelProps { config: Record; onChange: (config: Record) => void; inputType?: string; tableName?: string; columnName?: string; } export const V2SelectConfigPanel: React.FC = ({ config, onChange, inputType, tableName, columnName, }) => { const isEntityType = inputType === "entity" || config.source === "entity" || !!config.entityTable; const isCategoryType = inputType === "category"; const [entityColumns, setEntityColumns] = useState([]); const [loadingColumns, setLoadingColumns] = useState(false); const [categoryValues, setCategoryValues] = useState([]); const [loadingCategoryValues, setLoadingCategoryValues] = useState(false); const [filterColumns, setFilterColumns] = useState([]); const [loadingFilterColumns, setLoadingFilterColumns] = useState(false); const [advancedOpen, setAdvancedOpen] = useState(false); const updateConfig = (field: string, value: any) => { onChange({ ...config, [field]: value }); }; const filterTargetTable = useMemo(() => { const src = config.source || "static"; if (src === "entity") return config.entityTable; if (src === "db") return config.table; if (src === "distinct" || src === "select") return tableName; return null; }, [config.source, config.entityTable, config.table, tableName]); useEffect(() => { if (!filterTargetTable) { setFilterColumns([]); return; } const loadFilterColumns = async () => { setLoadingFilterColumns(true); try { const response = await apiClient.get(`/table-management/tables/${filterTargetTable}/columns?size=500`); const data = response.data.data || response.data; const columns = data.columns || data || []; setFilterColumns( columns.map((col: any) => ({ columnName: col.columnName || col.column_name || col.name, columnLabel: col.displayName || col.display_name || col.columnLabel || col.column_label || col.columnName || col.column_name || col.name, })) ); } catch { setFilterColumns([]); } finally { setLoadingFilterColumns(false); } }; loadFilterColumns(); }, [filterTargetTable]); useEffect(() => { if (isCategoryType && config.source !== "category") { onChange({ ...config, source: "category" }); } }, [isCategoryType]); const loadCategoryValues = useCallback(async (catTable: string, catColumn: string) => { if (!catTable || !catColumn) { setCategoryValues([]); return; } setLoadingCategoryValues(true); try { const response = await apiClient.get(`/table-categories/${catTable}/${catColumn}/values`); const data = response.data; if (data.success && data.data) { const flattenTree = (items: any[], depth: number = 0): CategoryValueOption[] => { const result: CategoryValueOption[] = []; for (const item of items) { result.push({ valueCode: item.valueCode, valueLabel: depth > 0 ? `${" ".repeat(depth)}${item.valueLabel}` : item.valueLabel, }); if (item.children && item.children.length > 0) { result.push(...flattenTree(item.children, depth + 1)); } } return result; }; setCategoryValues(flattenTree(data.data)); } } catch (error) { console.error("카테고리 값 조회 실패:", error); setCategoryValues([]); } finally { setLoadingCategoryValues(false); } }, []); useEffect(() => { if (config.source === "category" || config.source === "code") { const catTable = config.categoryTable || tableName; const catColumn = config.categoryColumn || columnName; if (catTable && catColumn) { loadCategoryValues(catTable, catColumn); } } }, [config.source, config.categoryTable, config.categoryColumn, tableName, columnName, loadCategoryValues]); const loadEntityColumns = useCallback(async (tblName: string) => { if (!tblName) { setEntityColumns([]); return; } setLoadingColumns(true); try { const response = await apiClient.get(`/table-management/tables/${tblName}/columns?size=500`); const data = response.data.data || response.data; const columns = data.columns || data || []; const columnOptions: ColumnOption[] = columns.map((col: any) => { const name = col.columnName || col.column_name || col.name; const label = col.displayName || col.display_name || col.columnLabel || col.column_label || name; return { columnName: name, columnLabel: label, }; }); setEntityColumns(columnOptions); } catch (error) { console.error("컬럼 목록 조회 실패:", error); setEntityColumns([]); } finally { setLoadingColumns(false); } }, []); useEffect(() => { if (config.source === "entity" && config.entityTable) { loadEntityColumns(config.entityTable); } }, [config.source, config.entityTable, loadEntityColumns]); const options = config.options || []; const addOption = () => { const newOptions = [...options, { value: "", label: "" }]; updateConfig("options", newOptions); }; const updateOptionValue = (index: number, value: string) => { const newOptions = [...options]; newOptions[index] = { ...newOptions[index], value, label: value }; updateConfig("options", newOptions); }; const removeOption = (index: number) => { const newOptions = options.filter((_: any, i: number) => i !== index); updateConfig("options", newOptions); }; const effectiveSource = isCategoryType ? "category" : config.source === "code" ? "category" : config.source || "static"; const visibleCards = useMemo(() => { if (isCategoryType) { return SOURCE_CARDS.filter((c) => c.value === "category"); } return SOURCE_CARDS.filter((c) => { if (c.entityOnly && !isEntityType) return false; return true; }); }, [isCategoryType, isEntityType]); const gridCols = isEntityType ? "grid-cols-3" : "grid-cols-2"; return (
{/* ─── 1단계: 데이터 소스 선택 ─── */}

이 필드는 어떤 데이터를 선택하나요?

{visibleCards.map((card) => { const Icon = card.icon; const isSelected = effectiveSource === card.value; return ( ); })}
{/* ─── 2단계: 소스별 설정 ─── */} {/* 직접 입력 (static) */} {effectiveSource === "static" && (
옵션 목록
{options.length > 0 ? (
{options.map((option: any, index: number) => (
updateOptionValue(index, e.target.value)} placeholder={`옵션 ${index + 1}`} className="h-8 flex-1 text-sm" />
))}
) : (

아직 옵션이 없어요

위의 추가 버튼으로 옵션을 만들어보세요

)} {options.length > 0 && (
기본 선택값
)}
)} {/* 테이블 참조 (entity) */} {effectiveSource === "entity" && (
테이블 참조

참조 테이블

{config.entityTable || "미설정"}

{loadingColumns && (
컬럼 목록 로딩 중...
)} {entityColumns.length > 0 && (

실제 저장되는 값

사용자에게 보여지는 텍스트

엔티티 선택 시 같은 폼의 관련 필드가 자동으로 채워져요

)} {!loadingColumns && entityColumns.length === 0 && !config.entityTable && (

참조 테이블이 설정되지 않았어요

테이블 타입 관리에서 참조 테이블을 설정해주세요

)} {config.entityTable && !loadingColumns && entityColumns.length === 0 && (

테이블 컬럼을 조회할 수 없습니다. 테이블 타입 관리에서 참조 테이블을 설정해주세요.

)}
)} {/* 카테고리 (category) - source="code" 하위 호환 포함 */} {effectiveSource === "category" && (
카테고리
{config.source === "code" && config.codeGroup && (

코드 그룹

{config.codeGroup}

테이블 컬럼에 설정된 코드 그룹이 자동으로 적용돼요

)}

테이블

{config.categoryTable || tableName || "-"}

컬럼

{config.categoryColumn || columnName || "-"}

{loadingCategoryValues && (
카테고리 값 로딩 중...
)} {categoryValues.length > 0 && (

{categoryValues.length}개의 값이 있어요

{categoryValues.map((cv) => (
{cv.valueCode} {cv.valueLabel}
))}
기본 선택값
)} {!loadingCategoryValues && categoryValues.length === 0 && (

카테고리 값이 없습니다. 테이블 카테고리 관리에서 값을 추가해주세요.

)}
)} {/* 데이터 필터 (static 제외, filterTargetTable 있을 때만) */} {effectiveSource !== "static" && filterTargetTable && (
updateConfig("filters", filters)} />
)} {/* ─── 3단계: 고급 설정 (기본 접혀있음) ─── */}
{/* 선택 모드 */}

선택 방식

대부분의 경우 드롭다운이 적합해요

{/* 토글 옵션들 */}

여러 개 선택

한 번에 여러 값을 선택할 수 있어요

updateConfig("multiple", checked)} />
{config.multiple && (
최대 선택 개수 updateConfig("maxSelect", e.target.value ? Number(e.target.value) : undefined)} placeholder="제한 없음" min={1} className="h-7 w-[100px] text-xs" />
)}

검색 기능

옵션이 많을 때 검색으로 찾을 수 있어요

updateConfig("searchable", checked)} />

선택 초기화

선택한 값을 지울 수 있는 X 버튼이 표시돼요

updateConfig("allowClear", checked)} />
); }; V2SelectConfigPanel.displayName = "V2SelectConfigPanel"; export default V2SelectConfigPanel;