ERP-node/frontend/components/v2/config-panels/V2FieldConfigPanel.tsx

750 lines
40 KiB
TypeScript
Raw Normal View History

"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;