Files
wace_rps/frontend/components/v2/config-panels/V2FieldConfigPanel.tsx
T
DDD1542 c3a43179e3 refactor: update color schemes and improve component styling
- Changed color schemes for various screen types and roles to align with the new design guidelines, enhancing visual consistency across the application.
- Updated background colors for components based on their types, such as changing 'bg-slate-400' to 'bg-muted-foreground' and adjusting other color mappings for better clarity.
- Improved the styling of the ScreenNode and V2PropertiesPanel components to ensure a more cohesive user experience.
- Enhanced the DynamicComponentRenderer to support dynamic loading of column metadata with cache invalidation for better performance.

These changes aim to refine the UI and improve the overall aesthetic of the application, ensuring a more modern and user-friendly interface.
2026-03-15 15:15:44 +09:00

758 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, metaInputType?: string): FieldType {
// DB input_type이 전달된 경우 (데이터타입관리에서 변경 시) 우선 적용
if (metaInputType && metaInputType !== "direct" && metaInputType !== "auto") {
const dbType = metaInputType as FieldType;
if (["text", "number", "textarea", "numbering", "select", "category", "entity"].includes(dbType)) {
return dbType;
}
}
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, metaInputType);
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;