"use client"; /** * V2 통합 필드 설정 패널 * 입력(text/number/textarea/numbering)과 선택(select/category/entity)을 * 하나의 패널에서 전환할 수 있는 통합 설정 UI */ import React, { useState, useEffect, useCallback, useMemo } from "react"; import { Label } from "@/components/ui/label"; 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 { Type, Hash, AlignLeft, ListOrdered, List, Database, FolderTree, Settings, ChevronDown, Plus, Trash2, Loader2, Filter, } from "lucide-react"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; import { AutoGenerationType } from "@/types/screen"; import { AutoGenerationUtils } from "@/lib/utils/autoGeneration"; import { getAvailableNumberingRulesForScreen } from "@/lib/api/numberingRule"; import { NumberingRuleConfig } from "@/types/numbering-rule"; import type { V2SelectFilter } from "@/types/v2-components"; // ─── 필드 유형 카드 정의 ─── const FIELD_TYPE_CARDS = [ { value: "text", icon: Type, label: "텍스트", desc: "일반 텍스트 입력", group: "input" }, { value: "number", icon: Hash, label: "숫자", desc: "숫자만 입력", group: "input" }, { value: "textarea", icon: AlignLeft, label: "여러 줄", desc: "긴 텍스트 입력", group: "input" }, { value: "select", icon: List, label: "셀렉트", desc: "직접 옵션 선택", group: "select" }, { value: "category", icon: FolderTree, label: "카테고리", desc: "등록된 선택지", group: "select" }, { value: "entity", icon: Database, label: "테이블 참조", desc: "다른 테이블 참조", group: "select" }, { value: "numbering", icon: ListOrdered, label: "채번", desc: "자동 번호 생성", group: "input" }, ] as const; type FieldType = typeof FIELD_TYPE_CARDS[number]["value"]; // 필터 조건 관련 상수 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; interface ColumnOption { columnName: string; columnLabel: string; } interface CategoryValueOption { valueCode: string; valueLabel: string; } // ─── 하위 호환: 기존 config에서 fieldType 추론 ─── function resolveFieldType(config: Record, componentType?: string): FieldType { if (config.fieldType) return config.fieldType as FieldType; // v2-select 계열 if (componentType === "v2-select" || config.source) { const source = config.source === "code" ? "category" : config.source; if (source === "entity") return "entity"; if (source === "category") return "category"; return "select"; } // v2-input 계열 const it = config.inputType || config.type; if (it === "number") return "number"; if (it === "textarea") return "textarea"; if (it === "numbering") return "numbering"; return "text"; } // ─── 필터 조건 서브 컴포넌트 ─── 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="참조할 필드명" className="h-7 flex-1 text-[11px]" /> )} {filter.valueType === "user" && ( )}
)}
))}
); }; // ─── 메인 컴포넌트 ─── interface V2FieldConfigPanelProps { config: Record; onChange: (config: Record) => void; tableName?: string; columnName?: string; tables?: Array<{ tableName: string; displayName?: string; tableComment?: string }>; menuObjid?: number; screenTableName?: string; inputType?: string; componentType?: string; } export const V2FieldConfigPanel: React.FC = ({ config, onChange, tableName, columnName, tables = [], screenTableName, inputType: metaInputType, componentType, }) => { const fieldType = resolveFieldType(config, componentType); const isSelectGroup = ["select", "category", "entity"].includes(fieldType); // ─── 채번 관련 상태 (테이블 기반) ─── const [numberingRules, setNumberingRules] = useState([]); const [loadingRules, setLoadingRules] = useState(false); const numberingTableName = screenTableName || tableName; // ─── 셀렉트 관련 상태 ─── 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 handleFieldTypeChange = (newType: FieldType) => { const newIsSelect = ["select", "category", "entity"].includes(newType); const base: Record = { ...config, fieldType: newType }; if (newIsSelect) { base.source = newType === "category" ? "category" : newType === "entity" ? "entity" : "static"; delete base.inputType; } else { base.inputType = newType; // 선택형 -> 입력형 전환 시 source 잔류 제거 (안 지우면 '카테고리 값이 없습니다' 같은 오류 표시) delete base.source; } if (newType === "numbering") { base.autoGeneration = { ...config.autoGeneration, type: "numbering_rule" as AutoGenerationType, tableName: numberingTableName, }; base.readonly = config.readonly ?? true; } onChange(base); // table_type_columns.input_type 동기화 (카테고리/엔티티 등 설정 가능하도록) const syncTableName = screenTableName || tableName; const syncColumnName = columnName || config.columnName || config.fieldName; if (syncTableName && syncColumnName) { apiClient.put(`/table-management/tables/${syncTableName}/columns/${syncColumnName}/input-type`, { inputType: newType, }).then(() => { // 왼쪽 테이블 패널의 컬럼 타입 뱃지 갱신 window.dispatchEvent(new CustomEvent("table-columns-refresh")); }).catch(() => { /* 동기화 실패해도 화면 설정은 유지 */ }); } }; // ─── 채번 규칙 로드 (테이블 기반) ─── useEffect(() => { if (fieldType !== "numbering") return; if (!numberingTableName) { setNumberingRules([]); return; } const load = async () => { setLoadingRules(true); try { const resp = await getAvailableNumberingRulesForScreen(numberingTableName); if (resp.success && resp.data) setNumberingRules(resp.data); else setNumberingRules([]); } catch { setNumberingRules([]); } finally { setLoadingRules(false); } }; load(); }, [numberingTableName, fieldType]); // ─── 엔티티 컬럼 로드 ─── const loadEntityColumns = useCallback(async (tblName: string) => { if (!tblName) { setEntityColumns([]); return; } setLoadingColumns(true); try { const resp = await apiClient.get(`/table-management/tables/${tblName}/columns?size=500`); const data = resp.data.data || resp.data; const cols = data.columns || data || []; setEntityColumns(cols.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 { setEntityColumns([]); } finally { setLoadingColumns(false); } }, []); useEffect(() => { if (fieldType === "entity" && config.entityTable) loadEntityColumns(config.entityTable); }, [fieldType, config.entityTable, loadEntityColumns]); // ─── 카테고리 값 로드 ─── const loadCategoryValues = useCallback(async (catTable: string, catColumn: string) => { if (!catTable || !catColumn) { setCategoryValues([]); return; } setLoadingCategoryValues(true); try { const resp = await apiClient.get(`/table-categories/${catTable}/${catColumn}/values`); if (resp.data.success && resp.data.data) { const flattenTree = (items: any[], depth = 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?.length) result.push(...flattenTree(item.children, depth + 1)); } return result; }; setCategoryValues(flattenTree(resp.data.data)); } } catch { setCategoryValues([]); } finally { setLoadingCategoryValues(false); } }, []); useEffect(() => { if (fieldType === "category") { const catTable = config.categoryTable || tableName; const catColumn = config.categoryColumn || columnName; if (catTable && catColumn) loadCategoryValues(catTable, catColumn); } }, [fieldType, config.categoryTable, config.categoryColumn, tableName, columnName, loadCategoryValues]); // ─── 필터 컬럼 로드 ─── const filterTargetTable = useMemo(() => { if (fieldType === "entity") return config.entityTable; if (fieldType === "category") return config.categoryTable || tableName; return null; }, [fieldType, config.entityTable, config.categoryTable, tableName]); useEffect(() => { if (!filterTargetTable) { setFilterColumns([]); return; } const load = async () => { setLoadingFilterColumns(true); try { const resp = await apiClient.get(`/table-management/tables/${filterTargetTable}/columns?size=500`); const data = resp.data.data || resp.data; const cols = data.columns || data || []; setFilterColumns(cols.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); } }; load(); }, [filterTargetTable]); // ─── 옵션 관리 (select static) ─── const options = config.options || []; const addOption = () => updateConfig("options", [...options, { value: "", label: "" }]); const updateOptionValue = (index: number, value: string) => { const newOpts = [...options]; newOpts[index] = { ...newOpts[index], value, label: value }; updateConfig("options", newOpts); }; const removeOption = (index: number) => updateConfig("options", options.filter((_: any, i: number) => i !== index)); return (
{/* ═══ 1단계: 필드 유형 선택 ═══ */}

이 필드는 어떤 유형인가요?

유형에 따라 입력 방식이 바뀌어요

{FIELD_TYPE_CARDS.map((card) => { const Icon = card.icon; const isSelected = fieldType === card.value; return ( ); })}
{/* ═══ 2단계: 유형별 상세 설정 ═══ */} {/* ─── 텍스트/숫자/여러줄: 기본 설정 ─── */} {(fieldType === "text" || fieldType === "number" || fieldType === "textarea") && (
안내 텍스트 updateConfig("placeholder", e.target.value)} placeholder="입력 안내" className="h-7 w-[160px] text-xs" />
{fieldType === "text" && (
입력 형식
)} {fieldType === "number" && (

값 범위

updateConfig("min", e.target.value ? Number(e.target.value) : undefined)} placeholder="0" className="h-7 text-xs" />
updateConfig("max", e.target.value ? Number(e.target.value) : undefined)} placeholder="100" className="h-7 text-xs" />
updateConfig("step", e.target.value ? Number(e.target.value) : undefined)} placeholder="1" className="h-7 text-xs" />
)} {fieldType === "textarea" && (
줄 수 updateConfig("rows", parseInt(e.target.value) || 3)} min={2} max={20} className="h-7 w-[160px] text-xs" />
)}
)} {/* ─── 셀렉트 (직접 입력): 옵션 관리 ─── */} {fieldType === "select" && (
옵션 목록
{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 && (
기본 선택값
)}
)} {/* ─── 카테고리 ─── */} {fieldType === "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 && (

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

)}
)} {/* ─── 테이블 참조 (entity) ─── */} {fieldType === "entity" && (
테이블 참조

참조 테이블

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

실제 저장되는 값

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

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

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

선택한 테이블의 컬럼 정보를 불러올 수 없어요.

)}
)} {/* ─── 채번 (테이블 기반) ─── */} {fieldType === "numbering" && (
채번 규칙
{numberingTableName ? (

대상 테이블

{numberingTableName}

) : (

화면에 연결된 테이블이 없어서 채번 규칙을 불러올 수 없어요.

)} {numberingTableName && (

채번 규칙

{loadingRules ? (
채번 규칙 로딩 중...
) : numberingRules.length > 0 ? ( ) : (

이 테이블에 등록된 채번 규칙이 없어요

)}
)}

읽기전용

채번 필드는 자동 생성되므로 읽기전용을 권장해요

updateConfig("readonly", checked)} />
)} {/* ─── 데이터 필터 (선택형 + 테이블 있을 때만) ─── */} {isSelectGroup && fieldType !== "select" && filterTargetTable && (
updateConfig("filters", filters)} />
)} {/* ═══ 3단계: 고급 설정 ═══ */}
{/* 선택형: 선택 방식, 복수 선택, 검색 등 */} {isSelectGroup && ( <>

선택 방식

여러 개 선택

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

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

검색 기능

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

updateConfig("searchable", v)} />

선택 초기화

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

updateConfig("allowClear", v)} />
)} {/* 입력형: 자동 생성 */} {!isSelectGroup && fieldType !== "numbering" && (

자동 생성

값이 자동으로 채워져요

updateConfig("autoGeneration", { ...config.autoGeneration || { type: "none", enabled: false }, enabled: checked })} />
{config.autoGeneration?.enabled && (

생성 방식

{config.autoGeneration?.type && config.autoGeneration.type !== "none" && (

{AutoGenerationUtils.getTypeDescription(config.autoGeneration.type)}

)}
)} {/* 입력 마스크 */}
입력 마스크

# = 숫자, A = 문자, * = 모두

updateConfig("mask", e.target.value)} placeholder="###-####-####" className="h-7 w-[140px] text-xs" />
)}
); }; V2FieldConfigPanel.displayName = "V2FieldConfigPanel"; export default V2FieldConfigPanel;