750 lines
40 KiB
TypeScript
750 lines
40 KiB
TypeScript
|
|
"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<string, any>, 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<V2SelectFilter>) => {
|
||
|
|
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 (
|
||
|
|
<div className="space-y-2">
|
||
|
|
<div className="flex items-center justify-between">
|
||
|
|
<div className="flex items-center gap-1.5">
|
||
|
|
<Filter className="h-3.5 w-3.5 text-muted-foreground" />
|
||
|
|
<span className="text-xs font-medium">데이터 필터</span>
|
||
|
|
</div>
|
||
|
|
<Button type="button" variant="ghost" size="sm" onClick={addFilter} className="h-6 px-2 text-xs">
|
||
|
|
<Plus className="mr-1 h-3 w-3" />추가
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
<p className="text-muted-foreground text-[10px]">{targetTable} 테이블에서 옵션을 불러올 때 적용할 조건</p>
|
||
|
|
{loadingColumns && (
|
||
|
|
<div className="text-muted-foreground flex items-center gap-2 text-xs"><Loader2 className="h-3 w-3 animate-spin" />컬럼 목록 로딩 중...</div>
|
||
|
|
)}
|
||
|
|
{filters.length === 0 && <p className="text-muted-foreground py-2 text-center text-xs">필터 조건이 없습니다</p>}
|
||
|
|
<div className="space-y-2">
|
||
|
|
{filters.map((filter, index) => (
|
||
|
|
<div key={index} className="space-y-2 rounded-md border p-3">
|
||
|
|
<div className="flex items-center gap-1.5">
|
||
|
|
<Select value={filter.column || ""} onValueChange={(v) => updateFilter(index, { column: v })}>
|
||
|
|
<SelectTrigger className="h-7 flex-1 text-[11px]"><SelectValue placeholder="컬럼" /></SelectTrigger>
|
||
|
|
<SelectContent>{columns.map((col) => (<SelectItem key={col.columnName} value={col.columnName}>{col.columnLabel}</SelectItem>))}</SelectContent>
|
||
|
|
</Select>
|
||
|
|
<Select value={filter.operator || "="} onValueChange={(v) => updateFilter(index, { operator: v as V2SelectFilter["operator"] })}>
|
||
|
|
<SelectTrigger className="h-7 flex-1 text-[11px]"><SelectValue /></SelectTrigger>
|
||
|
|
<SelectContent>{OPERATOR_OPTIONS.map((op) => (<SelectItem key={op.value} value={op.value}>{op.label}</SelectItem>))}</SelectContent>
|
||
|
|
</Select>
|
||
|
|
<Button type="button" variant="ghost" size="sm" onClick={() => removeFilter(index)} className="text-destructive h-8 w-8 shrink-0 p-0"><Trash2 className="h-3 w-3" /></Button>
|
||
|
|
</div>
|
||
|
|
{needsValue(filter.operator) && (
|
||
|
|
<div className="flex items-center gap-1.5">
|
||
|
|
<Select value={filter.valueType || "static"} onValueChange={(v) => updateFilter(index, { valueType: v as V2SelectFilter["valueType"] })}>
|
||
|
|
<SelectTrigger className="h-7 w-[100px] shrink-0 text-[11px]"><SelectValue /></SelectTrigger>
|
||
|
|
<SelectContent>{VALUE_TYPE_OPTIONS.map((vt) => (<SelectItem key={vt.value} value={vt.value}>{vt.label}</SelectItem>))}</SelectContent>
|
||
|
|
</Select>
|
||
|
|
{(filter.valueType || "static") === "static" && (
|
||
|
|
<Input value={String(filter.value ?? "")} onChange={(e) => 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" && (
|
||
|
|
<Input value={filter.fieldRef || ""} onChange={(e) => updateFilter(index, { fieldRef: e.target.value })} placeholder="참조할 필드명" className="h-7 flex-1 text-[11px]" />
|
||
|
|
)}
|
||
|
|
{filter.valueType === "user" && (
|
||
|
|
<Select value={filter.userField || ""} onValueChange={(v) => updateFilter(index, { userField: v as V2SelectFilter["userField"] })}>
|
||
|
|
<SelectTrigger className="h-7 flex-1 text-[11px]"><SelectValue placeholder="사용자 필드" /></SelectTrigger>
|
||
|
|
<SelectContent>{USER_FIELD_OPTIONS.map((uf) => (<SelectItem key={uf.value} value={uf.value}>{uf.label}</SelectItem>))}</SelectContent>
|
||
|
|
</Select>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
};
|
||
|
|
|
||
|
|
// ─── 메인 컴포넌트 ───
|
||
|
|
|
||
|
|
interface V2FieldConfigPanelProps {
|
||
|
|
config: Record<string, any>;
|
||
|
|
onChange: (config: Record<string, any>) => 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<V2FieldConfigPanelProps> = ({
|
||
|
|
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<NumberingRuleConfig[]>([]);
|
||
|
|
const [loadingRules, setLoadingRules] = useState(false);
|
||
|
|
const numberingTableName = screenTableName || tableName;
|
||
|
|
|
||
|
|
// ─── 셀렉트 관련 상태 ───
|
||
|
|
const [entityColumns, setEntityColumns] = useState<ColumnOption[]>([]);
|
||
|
|
const [loadingColumns, setLoadingColumns] = useState(false);
|
||
|
|
const [categoryValues, setCategoryValues] = useState<CategoryValueOption[]>([]);
|
||
|
|
const [loadingCategoryValues, setLoadingCategoryValues] = useState(false);
|
||
|
|
const [filterColumns, setFilterColumns] = useState<ColumnOption[]>([]);
|
||
|
|
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<string, any> = { ...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 (
|
||
|
|
<div className="space-y-4">
|
||
|
|
{/* ═══ 1단계: 필드 유형 선택 ═══ */}
|
||
|
|
<div className="space-y-2">
|
||
|
|
<p className="text-sm font-medium">이 필드는 어떤 유형인가요?</p>
|
||
|
|
<p className="text-[11px] text-muted-foreground">유형에 따라 입력 방식이 바뀌어요</p>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="grid grid-cols-3 gap-2">
|
||
|
|
{FIELD_TYPE_CARDS.map((card) => {
|
||
|
|
const Icon = card.icon;
|
||
|
|
const isSelected = fieldType === card.value;
|
||
|
|
return (
|
||
|
|
<button
|
||
|
|
key={card.value}
|
||
|
|
type="button"
|
||
|
|
onClick={() => handleFieldTypeChange(card.value)}
|
||
|
|
className={cn(
|
||
|
|
"flex flex-col items-center justify-center rounded-lg border p-2.5 text-center transition-all min-h-[72px]",
|
||
|
|
isSelected
|
||
|
|
? "border-primary bg-primary/5 ring-1 ring-primary/20"
|
||
|
|
: "border-border hover:border-primary/50 hover:bg-muted/50"
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
<Icon className={cn("h-4 w-4 mb-1", isSelected ? "text-primary" : "text-muted-foreground")} />
|
||
|
|
<span className={cn("text-[11px] font-medium leading-tight", isSelected ? "text-primary" : "text-foreground")}>{card.label}</span>
|
||
|
|
<span className="text-[9px] text-muted-foreground leading-tight mt-0.5">{card.desc}</span>
|
||
|
|
</button>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* ═══ 2단계: 유형별 상세 설정 ═══ */}
|
||
|
|
|
||
|
|
{/* ─── 텍스트/숫자/여러줄: 기본 설정 ─── */}
|
||
|
|
{(fieldType === "text" || fieldType === "number" || fieldType === "textarea") && (
|
||
|
|
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
|
||
|
|
<div className="flex items-center justify-between py-1">
|
||
|
|
<span className="text-xs text-muted-foreground">안내 텍스트</span>
|
||
|
|
<Input value={config.placeholder || ""} onChange={(e) => updateConfig("placeholder", e.target.value)} placeholder="입력 안내" className="h-7 w-[160px] text-xs" />
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{fieldType === "text" && (
|
||
|
|
<div className="flex items-center justify-between py-1">
|
||
|
|
<span className="text-xs text-muted-foreground">입력 형식</span>
|
||
|
|
<Select value={config.format || "none"} onValueChange={(v) => updateConfig("format", v)}>
|
||
|
|
<SelectTrigger className="h-7 w-[160px] text-xs"><SelectValue placeholder="형식 선택" /></SelectTrigger>
|
||
|
|
<SelectContent>
|
||
|
|
<SelectItem value="none">제한 없음</SelectItem>
|
||
|
|
<SelectItem value="email">이메일</SelectItem>
|
||
|
|
<SelectItem value="tel">전화번호</SelectItem>
|
||
|
|
<SelectItem value="url">URL</SelectItem>
|
||
|
|
<SelectItem value="currency">통화</SelectItem>
|
||
|
|
<SelectItem value="biz_no">사업자번호</SelectItem>
|
||
|
|
</SelectContent>
|
||
|
|
</Select>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{fieldType === "number" && (
|
||
|
|
<div className="space-y-2 pt-1">
|
||
|
|
<p className="text-xs text-muted-foreground">값 범위</p>
|
||
|
|
<div className="flex gap-2">
|
||
|
|
<div className="flex-1">
|
||
|
|
<Label className="text-[10px] text-muted-foreground">최소값</Label>
|
||
|
|
<Input type="number" value={config.min ?? ""} onChange={(e) => updateConfig("min", e.target.value ? Number(e.target.value) : undefined)} placeholder="0" className="h-7 text-xs" />
|
||
|
|
</div>
|
||
|
|
<div className="flex-1">
|
||
|
|
<Label className="text-[10px] text-muted-foreground">최대값</Label>
|
||
|
|
<Input type="number" value={config.max ?? ""} onChange={(e) => updateConfig("max", e.target.value ? Number(e.target.value) : undefined)} placeholder="100" className="h-7 text-xs" />
|
||
|
|
</div>
|
||
|
|
<div className="flex-1">
|
||
|
|
<Label className="text-[10px] text-muted-foreground">단계</Label>
|
||
|
|
<Input type="number" value={config.step ?? ""} onChange={(e) => updateConfig("step", e.target.value ? Number(e.target.value) : undefined)} placeholder="1" className="h-7 text-xs" />
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{fieldType === "textarea" && (
|
||
|
|
<div className="flex items-center justify-between py-1">
|
||
|
|
<span className="text-xs text-muted-foreground">줄 수</span>
|
||
|
|
<Input type="number" value={config.rows || 3} onChange={(e) => updateConfig("rows", parseInt(e.target.value) || 3)} min={2} max={20} className="h-7 w-[160px] text-xs" />
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* ─── 셀렉트 (직접 입력): 옵션 관리 ─── */}
|
||
|
|
{fieldType === "select" && (
|
||
|
|
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
|
||
|
|
<div className="flex items-center justify-between">
|
||
|
|
<span className="text-sm font-medium">옵션 목록</span>
|
||
|
|
<Button type="button" variant="outline" size="sm" onClick={addOption} className="h-7 px-2 text-xs">
|
||
|
|
<Plus className="mr-1 h-3 w-3" />추가
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
{options.length > 0 ? (
|
||
|
|
<div className="max-h-40 space-y-1.5 overflow-y-auto">
|
||
|
|
{options.map((option: any, index: number) => (
|
||
|
|
<div key={index} className="flex items-center gap-2">
|
||
|
|
<Input value={option.value || ""} onChange={(e) => updateOptionValue(index, e.target.value)} placeholder={`옵션 ${index + 1}`} className="h-8 flex-1 text-sm" />
|
||
|
|
<Button type="button" variant="ghost" size="icon" onClick={() => removeOption(index)} className="text-destructive h-8 w-8 shrink-0"><Trash2 className="h-4 w-4" /></Button>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<div className="text-center py-6 text-muted-foreground">
|
||
|
|
<List className="mx-auto mb-2 h-8 w-8 opacity-30" />
|
||
|
|
<p className="text-sm">아직 옵션이 없어요</p>
|
||
|
|
<p className="text-xs">위의 추가 버튼으로 옵션을 만들어보세요</p>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
{options.length > 0 && (
|
||
|
|
<div className="border-t pt-3 mt-3">
|
||
|
|
<div className="flex items-center justify-between">
|
||
|
|
<span className="text-xs text-muted-foreground">기본 선택값</span>
|
||
|
|
<Select value={config.defaultValue || "_none_"} onValueChange={(v) => updateConfig("defaultValue", v === "_none_" ? "" : v)}>
|
||
|
|
<SelectTrigger className="h-8 w-[160px] text-sm"><SelectValue placeholder="선택 안함" /></SelectTrigger>
|
||
|
|
<SelectContent>
|
||
|
|
<SelectItem value="_none_">선택 안함</SelectItem>
|
||
|
|
{options.map((opt: any, i: number) => (<SelectItem key={`d-${i}`} value={opt.value || `_idx_${i}`}>{opt.label || opt.value || `옵션 ${i + 1}`}</SelectItem>))}
|
||
|
|
</SelectContent>
|
||
|
|
</Select>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* ─── 카테고리 ─── */}
|
||
|
|
{fieldType === "category" && (
|
||
|
|
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<FolderTree className="h-4 w-4 text-primary" />
|
||
|
|
<span className="text-sm font-medium">카테고리</span>
|
||
|
|
</div>
|
||
|
|
{config.source === "code" && config.codeGroup && (
|
||
|
|
<div className="rounded-md border bg-background p-3">
|
||
|
|
<p className="text-xs text-muted-foreground">코드 그룹</p>
|
||
|
|
<p className="mt-0.5 text-sm font-medium">{config.codeGroup}</p>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
<div className="rounded-md border bg-background p-3">
|
||
|
|
<div className="flex gap-6">
|
||
|
|
<div><p className="text-xs text-muted-foreground">테이블</p><p className="text-sm font-medium">{config.categoryTable || tableName || "-"}</p></div>
|
||
|
|
<div><p className="text-xs text-muted-foreground">컬럼</p><p className="text-sm font-medium">{config.categoryColumn || columnName || "-"}</p></div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
{loadingCategoryValues && <div className="text-muted-foreground flex items-center gap-2 text-xs"><Loader2 className="h-3 w-3 animate-spin" />카테고리 값 로딩 중...</div>}
|
||
|
|
{categoryValues.length > 0 && (
|
||
|
|
<div>
|
||
|
|
<p className="mb-1.5 text-xs text-muted-foreground">{categoryValues.length}개의 값이 있어요</p>
|
||
|
|
<div className="max-h-28 overflow-y-auto rounded-md border bg-background p-2 space-y-0.5">
|
||
|
|
{categoryValues.map((cv) => (
|
||
|
|
<div key={cv.valueCode} className="flex items-center gap-2 px-1.5 py-0.5 text-xs">
|
||
|
|
<span className="shrink-0 font-mono text-[10px] text-muted-foreground">{cv.valueCode}</span>
|
||
|
|
<span className="truncate">{cv.valueLabel}</span>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
<div className="mt-3 flex items-center justify-between">
|
||
|
|
<span className="text-xs text-muted-foreground">기본 선택값</span>
|
||
|
|
<Select value={config.defaultValue || "_none_"} onValueChange={(v) => updateConfig("defaultValue", v === "_none_" ? "" : v)}>
|
||
|
|
<SelectTrigger className="h-8 w-[160px] text-sm"><SelectValue placeholder="선택 안함" /></SelectTrigger>
|
||
|
|
<SelectContent>
|
||
|
|
<SelectItem value="_none_">선택 안함</SelectItem>
|
||
|
|
{categoryValues.map((cv) => (<SelectItem key={cv.valueCode} value={cv.valueCode}>{cv.valueLabel}</SelectItem>))}
|
||
|
|
</SelectContent>
|
||
|
|
</Select>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
{!loadingCategoryValues && categoryValues.length === 0 && (
|
||
|
|
<p className="text-[10px] text-amber-600">카테고리 값이 없습니다. 테이블 카테고리 관리에서 값을 추가해주세요.</p>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* ─── 테이블 참조 (entity) ─── */}
|
||
|
|
{fieldType === "entity" && (
|
||
|
|
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<Database className="h-4 w-4 text-primary" />
|
||
|
|
<span className="text-sm font-medium">테이블 참조</span>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<p className="mb-1.5 text-xs text-muted-foreground">참조 테이블</p>
|
||
|
|
<Select value={config.entityTable || ""} onValueChange={(v) => onChange({ ...config, entityTable: v, entityValueColumn: "", entityLabelColumn: "" })}>
|
||
|
|
<SelectTrigger className="h-8 text-sm"><SelectValue placeholder="테이블을 선택해주세요" /></SelectTrigger>
|
||
|
|
<SelectContent>
|
||
|
|
{tables.map((t) => (<SelectItem key={t.tableName} value={t.tableName}>{t.displayName || t.tableComment ? `${t.displayName || t.tableComment} (${t.tableName})` : t.tableName}</SelectItem>))}
|
||
|
|
</SelectContent>
|
||
|
|
</Select>
|
||
|
|
</div>
|
||
|
|
{loadingColumns && <div className="text-muted-foreground flex items-center gap-2 text-xs"><Loader2 className="h-3 w-3 animate-spin" />컬럼 목록 로딩 중...</div>}
|
||
|
|
{entityColumns.length > 0 && (
|
||
|
|
<div className="space-y-3">
|
||
|
|
<div>
|
||
|
|
<p className="mb-1.5 text-xs text-muted-foreground">실제 저장되는 값</p>
|
||
|
|
<Select value={config.entityValueColumn || ""} onValueChange={(v) => updateConfig("entityValueColumn", v)}>
|
||
|
|
<SelectTrigger className="h-8 text-sm"><SelectValue placeholder="컬럼 선택" /></SelectTrigger>
|
||
|
|
<SelectContent>{entityColumns.map((col) => (<SelectItem key={col.columnName} value={col.columnName}>{col.columnLabel}</SelectItem>))}</SelectContent>
|
||
|
|
</Select>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<p className="mb-1.5 text-xs text-muted-foreground">사용자에게 보여지는 텍스트</p>
|
||
|
|
<Select value={config.entityLabelColumn || ""} onValueChange={(v) => updateConfig("entityLabelColumn", v)}>
|
||
|
|
<SelectTrigger className="h-8 text-sm"><SelectValue placeholder="컬럼 선택" /></SelectTrigger>
|
||
|
|
<SelectContent>{entityColumns.map((col) => (<SelectItem key={col.columnName} value={col.columnName}>{col.columnLabel}</SelectItem>))}</SelectContent>
|
||
|
|
</Select>
|
||
|
|
</div>
|
||
|
|
<p className="text-[11px] text-muted-foreground">엔티티 선택 시 같은 폼의 관련 필드가 자동으로 채워져요</p>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
{config.entityTable && !loadingColumns && entityColumns.length === 0 && (
|
||
|
|
<p className="text-[10px] text-amber-600">선택한 테이블의 컬럼 정보를 불러올 수 없어요.</p>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* ─── 채번 (테이블 기반) ─── */}
|
||
|
|
{fieldType === "numbering" && (
|
||
|
|
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<ListOrdered className="h-4 w-4 text-primary" />
|
||
|
|
<span className="text-sm font-medium">채번 규칙</span>
|
||
|
|
</div>
|
||
|
|
{numberingTableName ? (
|
||
|
|
<div className="rounded-md border bg-background p-2">
|
||
|
|
<p className="text-xs text-muted-foreground">대상 테이블</p>
|
||
|
|
<p className="text-sm font-medium mt-0.5">{numberingTableName}</p>
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<p className="text-xs text-amber-600">화면에 연결된 테이블이 없어서 채번 규칙을 불러올 수 없어요.</p>
|
||
|
|
)}
|
||
|
|
{numberingTableName && (
|
||
|
|
<div>
|
||
|
|
<p className="mb-1.5 text-xs text-muted-foreground">채번 규칙</p>
|
||
|
|
{loadingRules ? (
|
||
|
|
<div className="text-muted-foreground flex items-center gap-2 text-xs py-1"><Loader2 className="h-3 w-3 animate-spin" />채번 규칙 로딩 중...</div>
|
||
|
|
) : numberingRules.length > 0 ? (
|
||
|
|
<Select value={config.autoGeneration?.numberingRuleId || ""} onValueChange={(v) => {
|
||
|
|
onChange({ ...config, autoGeneration: { ...config.autoGeneration, type: "numbering_rule" as AutoGenerationType, numberingRuleId: v, tableName: numberingTableName } });
|
||
|
|
}}>
|
||
|
|
<SelectTrigger className="h-8 text-sm"><SelectValue placeholder="채번 규칙 선택" /></SelectTrigger>
|
||
|
|
<SelectContent>
|
||
|
|
{numberingRules.map((rule) => (<SelectItem key={rule.ruleId} value={String(rule.ruleId)}>{rule.ruleName} ({rule.separator || "-"}{"{번호}"})</SelectItem>))}
|
||
|
|
</SelectContent>
|
||
|
|
</Select>
|
||
|
|
) : (
|
||
|
|
<p className="text-xs text-muted-foreground">이 테이블에 등록된 채번 규칙이 없어요</p>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
<div className="flex items-center justify-between py-1">
|
||
|
|
<div>
|
||
|
|
<p className="text-sm">읽기전용</p>
|
||
|
|
<p className="text-[11px] text-muted-foreground">채번 필드는 자동 생성되므로 읽기전용을 권장해요</p>
|
||
|
|
</div>
|
||
|
|
<Switch checked={config.readonly !== false} onCheckedChange={(checked) => updateConfig("readonly", checked)} />
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* ─── 데이터 필터 (선택형 + 테이블 있을 때만) ─── */}
|
||
|
|
{isSelectGroup && fieldType !== "select" && filterTargetTable && (
|
||
|
|
<div className="rounded-lg border bg-muted/30 p-4">
|
||
|
|
<FilterConditionsSection
|
||
|
|
filters={(config.filters as V2SelectFilter[]) || []}
|
||
|
|
columns={filterColumns}
|
||
|
|
loadingColumns={loadingFilterColumns}
|
||
|
|
targetTable={filterTargetTable}
|
||
|
|
onFiltersChange={(filters) => updateConfig("filters", filters)}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* ═══ 3단계: 고급 설정 ═══ */}
|
||
|
|
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
||
|
|
<CollapsibleTrigger asChild>
|
||
|
|
<button type="button" className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50">
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<Settings className="h-4 w-4 text-muted-foreground" />
|
||
|
|
<span className="text-sm font-medium">고급 설정</span>
|
||
|
|
</div>
|
||
|
|
<ChevronDown className={cn("h-4 w-4 text-muted-foreground transition-transform duration-200", advancedOpen && "rotate-180")} />
|
||
|
|
</button>
|
||
|
|
</CollapsibleTrigger>
|
||
|
|
<CollapsibleContent>
|
||
|
|
<div className="rounded-b-lg border border-t-0 p-4 space-y-3">
|
||
|
|
{/* 선택형: 선택 방식, 복수 선택, 검색 등 */}
|
||
|
|
{isSelectGroup && (
|
||
|
|
<>
|
||
|
|
<div>
|
||
|
|
<p className="mb-1.5 text-xs text-muted-foreground">선택 방식</p>
|
||
|
|
<Select value={config.mode || "dropdown"} onValueChange={(v) => updateConfig("mode", v)}>
|
||
|
|
<SelectTrigger className="h-8 text-sm"><SelectValue /></SelectTrigger>
|
||
|
|
<SelectContent>
|
||
|
|
<SelectItem value="dropdown">드롭다운</SelectItem>
|
||
|
|
<SelectItem value="combobox">검색 가능 드롭다운</SelectItem>
|
||
|
|
<SelectItem value="radio">라디오 버튼</SelectItem>
|
||
|
|
<SelectItem value="check">체크박스</SelectItem>
|
||
|
|
<Separator className="my-1" />
|
||
|
|
<SelectItem value="tag">태그 선택</SelectItem>
|
||
|
|
<SelectItem value="toggle">토글</SelectItem>
|
||
|
|
</SelectContent>
|
||
|
|
</Select>
|
||
|
|
</div>
|
||
|
|
<div className="space-y-2">
|
||
|
|
<div className="flex items-center justify-between py-1">
|
||
|
|
<div><p className="text-sm">여러 개 선택</p><p className="text-[11px] text-muted-foreground">한 번에 여러 값을 선택할 수 있어요</p></div>
|
||
|
|
<Switch checked={config.multiple || false} onCheckedChange={(v) => updateConfig("multiple", v)} />
|
||
|
|
</div>
|
||
|
|
{config.multiple && (
|
||
|
|
<div className="ml-4 border-l-2 border-primary/20 pl-3">
|
||
|
|
<div className="flex items-center justify-between py-1">
|
||
|
|
<span className="text-xs text-muted-foreground">최대 선택 개수</span>
|
||
|
|
<Input type="number" value={config.maxSelect ?? ""} onChange={(e) => updateConfig("maxSelect", e.target.value ? Number(e.target.value) : undefined)} placeholder="제한 없음" min={1} className="h-7 w-[100px] text-xs" />
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
<div className="flex items-center justify-between py-1">
|
||
|
|
<div><p className="text-sm">검색 기능</p><p className="text-[11px] text-muted-foreground">옵션이 많을 때 검색으로 찾을 수 있어요</p></div>
|
||
|
|
<Switch checked={config.searchable || false} onCheckedChange={(v) => updateConfig("searchable", v)} />
|
||
|
|
</div>
|
||
|
|
<div className="flex items-center justify-between py-1">
|
||
|
|
<div><p className="text-sm">선택 초기화</p><p className="text-[11px] text-muted-foreground">선택한 값을 지울 수 있는 X 버튼이 표시돼요</p></div>
|
||
|
|
<Switch checked={config.allowClear !== false} onCheckedChange={(v) => updateConfig("allowClear", v)} />
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* 입력형: 자동 생성 */}
|
||
|
|
{!isSelectGroup && fieldType !== "numbering" && (
|
||
|
|
<div className="space-y-3">
|
||
|
|
<div className="flex items-center justify-between py-1">
|
||
|
|
<div><p className="text-sm">자동 생성</p><p className="text-[11px] text-muted-foreground">값이 자동으로 채워져요</p></div>
|
||
|
|
<Switch checked={config.autoGeneration?.enabled || false} onCheckedChange={(checked) => updateConfig("autoGeneration", { ...config.autoGeneration || { type: "none", enabled: false }, enabled: checked })} />
|
||
|
|
</div>
|
||
|
|
{config.autoGeneration?.enabled && (
|
||
|
|
<div className="ml-1 border-l-2 border-primary/20 pl-3">
|
||
|
|
<div>
|
||
|
|
<p className="mb-1.5 text-xs text-muted-foreground">생성 방식</p>
|
||
|
|
<Select value={config.autoGeneration?.type || "none"} onValueChange={(v: AutoGenerationType) => updateConfig("autoGeneration", { ...config.autoGeneration, type: v })}>
|
||
|
|
<SelectTrigger className="h-8 text-sm"><SelectValue placeholder="자동생성 타입 선택" /></SelectTrigger>
|
||
|
|
<SelectContent>
|
||
|
|
<SelectItem value="none">자동생성 없음</SelectItem>
|
||
|
|
<SelectItem value="uuid">UUID 생성</SelectItem>
|
||
|
|
<SelectItem value="current_user">현재 사용자 ID</SelectItem>
|
||
|
|
<SelectItem value="current_time">현재 시간</SelectItem>
|
||
|
|
<SelectItem value="sequence">순차 번호</SelectItem>
|
||
|
|
<SelectItem value="company_code">회사 코드</SelectItem>
|
||
|
|
<SelectItem value="department">부서 코드</SelectItem>
|
||
|
|
</SelectContent>
|
||
|
|
</Select>
|
||
|
|
</div>
|
||
|
|
{config.autoGeneration?.type && config.autoGeneration.type !== "none" && (
|
||
|
|
<p className="text-[11px] text-muted-foreground mt-1">{AutoGenerationUtils.getTypeDescription(config.autoGeneration.type)}</p>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* 입력 마스크 */}
|
||
|
|
<div className="flex items-center justify-between py-1">
|
||
|
|
<div>
|
||
|
|
<span className="text-xs text-muted-foreground">입력 마스크</span>
|
||
|
|
<p className="text-[10px] text-muted-foreground mt-0.5"># = 숫자, A = 문자, * = 모두</p>
|
||
|
|
</div>
|
||
|
|
<Input value={config.mask || ""} onChange={(e) => updateConfig("mask", e.target.value)} placeholder="###-####-####" className="h-7 w-[140px] text-xs" />
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</CollapsibleContent>
|
||
|
|
</Collapsible>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
};
|
||
|
|
|
||
|
|
V2FieldConfigPanel.displayName = "V2FieldConfigPanel";
|
||
|
|
|
||
|
|
export default V2FieldConfigPanel;
|