엔티티타입 입력 셀렉트박스 다중선택 기능

This commit is contained in:
kjs
2026-01-08 14:49:24 +09:00
parent 3f81c449ad
commit 11e25694b9
11 changed files with 547 additions and 99 deletions

View File

@@ -35,7 +35,9 @@ export function EntitySearchInputComponent({
parentValue: parentValueProp,
parentFieldId,
formData,
// 🆕 추가 props
// 다중선택 props
multiple: multipleProp,
// 추가 props
component,
isInteractive,
onFormDataChange,
@@ -49,8 +51,11 @@ export function EntitySearchInputComponent({
// uiMode가 있으면 우선 사용, 없으면 modeProp 사용, 기본값 "combo"
const mode = (uiMode || modeProp || "combo") as "select" | "modal" | "combo" | "autocomplete";
// 연쇄관계 설정 추출 (webTypeConfig 또는 component.componentConfig서)
const config = component?.componentConfig || {};
// 다중선택 및 연쇄관계 설정 (props > webTypeConfig > componentConfig서)
const config = component?.componentConfig || component?.webTypeConfig || {};
const isMultiple = multipleProp ?? config.multiple ?? false;
// 연쇄관계 설정 추출
const effectiveCascadingRelationCode = cascadingRelationCode || config.cascadingRelationCode;
// cascadingParentField: ConfigPanel에서 저장되는 필드명
const effectiveParentFieldId = parentFieldId || config.cascadingParentField || config.parentFieldId;
@@ -68,11 +73,27 @@ export function EntitySearchInputComponent({
const [isLoadingOptions, setIsLoadingOptions] = useState(false);
const [optionsLoaded, setOptionsLoaded] = useState(false);
// 다중선택 상태 (콤마로 구분된 값들)
const [selectedValues, setSelectedValues] = useState<string[]>([]);
const [selectedDataList, setSelectedDataList] = useState<EntitySearchResult[]>([]);
// 연쇄관계 상태
const [cascadingOptions, setCascadingOptions] = useState<EntitySearchResult[]>([]);
const [isCascadingLoading, setIsCascadingLoading] = useState(false);
const previousParentValue = useRef<any>(null);
// 다중선택 초기값 설정
useEffect(() => {
if (isMultiple && value) {
const vals =
typeof value === "string" ? value.split(",").filter(Boolean) : Array.isArray(value) ? value : [value];
setSelectedValues(vals.map(String));
} else if (isMultiple && !value) {
setSelectedValues([]);
setSelectedDataList([]);
}
}, [isMultiple, value]);
// 부모 필드 값 결정 (직접 전달 또는 formData에서 추출) - 자식 역할일 때만 필요
const parentValue = isChildRole
? (parentValueProp ?? (effectiveParentFieldId && formData ? formData[effectiveParentFieldId] : undefined))
@@ -249,23 +270,75 @@ export function EntitySearchInputComponent({
}, [value, displayField, effectiveOptions, mode, valueField, tableName, selectedData]);
const handleSelect = (newValue: any, fullData: EntitySearchResult) => {
setSelectedData(fullData);
setDisplayValue(fullData[displayField] || "");
onChange?.(newValue, fullData);
if (isMultiple) {
// 다중선택 모드
const valueStr = String(newValue);
const isAlreadySelected = selectedValues.includes(valueStr);
let newSelectedValues: string[];
let newSelectedDataList: EntitySearchResult[];
if (isAlreadySelected) {
// 이미 선택된 항목이면 제거
newSelectedValues = selectedValues.filter((v) => v !== valueStr);
newSelectedDataList = selectedDataList.filter((d) => String(d[valueField]) !== valueStr);
} else {
// 선택되지 않은 항목이면 추가
newSelectedValues = [...selectedValues, valueStr];
newSelectedDataList = [...selectedDataList, fullData];
}
setSelectedValues(newSelectedValues);
setSelectedDataList(newSelectedDataList);
const joinedValue = newSelectedValues.join(",");
onChange?.(joinedValue, newSelectedDataList);
if (isInteractive && onFormDataChange && component?.columnName) {
onFormDataChange(component.columnName, joinedValue);
console.log("📤 EntitySearchInput (multiple) -> onFormDataChange:", component.columnName, joinedValue);
}
} else {
// 단일선택 모드
setSelectedData(fullData);
setDisplayValue(fullData[displayField] || "");
onChange?.(newValue, fullData);
if (isInteractive && onFormDataChange && component?.columnName) {
onFormDataChange(component.columnName, newValue);
console.log("📤 EntitySearchInput -> onFormDataChange:", component.columnName, newValue);
}
}
};
// 다중선택 모드에서 개별 항목 제거
const handleRemoveValue = (valueToRemove: string) => {
const newSelectedValues = selectedValues.filter((v) => v !== valueToRemove);
const newSelectedDataList = selectedDataList.filter((d) => String(d[valueField]) !== valueToRemove);
setSelectedValues(newSelectedValues);
setSelectedDataList(newSelectedDataList);
const joinedValue = newSelectedValues.join(",");
onChange?.(joinedValue || null, newSelectedDataList);
// 🆕 onFormDataChange 호출 (formData에 값 저장)
if (isInteractive && onFormDataChange && component?.columnName) {
onFormDataChange(component.columnName, newValue);
console.log("📤 EntitySearchInput -> onFormDataChange:", component.columnName, newValue);
onFormDataChange(component.columnName, joinedValue || null);
console.log("📤 EntitySearchInput (remove) -> onFormDataChange:", component.columnName, joinedValue);
}
};
const handleClear = () => {
setDisplayValue("");
setSelectedData(null);
onChange?.(null, null);
if (isMultiple) {
setSelectedValues([]);
setSelectedDataList([]);
onChange?.(null, []);
} else {
setDisplayValue("");
setSelectedData(null);
onChange?.(null, null);
}
// 🆕 onFormDataChange 호출 (formData에서 값 제거)
if (isInteractive && onFormDataChange && component?.columnName) {
onFormDataChange(component.columnName, null);
console.log("📤 EntitySearchInput -> onFormDataChange (clear):", component.columnName, null);
@@ -280,7 +353,10 @@ export function EntitySearchInputComponent({
const handleSelectOption = (option: EntitySearchResult) => {
handleSelect(option[valueField], option);
setSelectOpen(false);
// 다중선택이 아닌 경우에만 드롭다운 닫기
if (!isMultiple) {
setSelectOpen(false);
}
};
// 높이 계산 (style에서 height가 있으면 사용, 없으면 기본값)
@@ -289,6 +365,111 @@ export function EntitySearchInputComponent({
// select 모드: 검색 가능한 드롭다운
if (mode === "select") {
// 다중선택 모드
if (isMultiple) {
return (
<div className={cn("relative flex flex-col", className)} style={style}>
{/* 라벨 렌더링 */}
{component?.label && component?.style?.labelDisplay !== false && (
<label className="text-muted-foreground absolute -top-6 left-0 text-sm font-medium">
{component.label}
{component.required && <span className="text-destructive">*</span>}
</label>
)}
{/* 선택된 항목들 표시 (태그 형식) */}
<div
className={cn(
"box-border flex min-h-[40px] w-full flex-wrap items-center gap-2 rounded-md border bg-white px-3 py-2",
!disabled && "hover:border-primary/50",
disabled && "cursor-not-allowed bg-gray-100 opacity-60",
)}
onClick={() => !disabled && !isLoading && setSelectOpen(true)}
style={{ cursor: disabled ? "not-allowed" : "pointer" }}
>
{selectedValues.length > 0 ? (
selectedValues.map((val) => {
const opt = effectiveOptions.find((o) => String(o[valueField]) === val);
const label = opt?.[displayField] || val;
return (
<span
key={val}
className="flex items-center gap-1 rounded bg-blue-100 px-2 py-1 text-sm text-blue-800"
>
{label}
{!disabled && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleRemoveValue(val);
}}
className="ml-1 text-blue-600 hover:text-blue-800"
>
×
</button>
)}
</span>
);
})
) : (
<span className="text-muted-foreground text-sm">
{isLoading
? "로딩 중..."
: shouldApplyCascading && !parentValue
? "상위 항목을 먼저 선택하세요"
: placeholder}
</span>
)}
<ChevronsUpDown className="ml-auto h-4 w-4 shrink-0 opacity-50" />
</div>
{/* 옵션 드롭다운 */}
{selectOpen && !disabled && (
<div className="absolute top-full left-0 z-50 mt-1 w-full rounded-md border bg-white shadow-md">
<Command>
<CommandInput placeholder={`${displayField} 검색...`} className="text-xs sm:text-sm" />
<CommandList className="max-h-60">
<CommandEmpty className="py-4 text-center text-xs sm:text-sm"> .</CommandEmpty>
<CommandGroup>
{effectiveOptions.map((option, index) => {
const isSelected = selectedValues.includes(String(option[valueField]));
return (
<CommandItem
key={option[valueField] || index}
value={`${option[displayField] || ""}-${option[valueField] || ""}`}
onSelect={() => handleSelectOption(option)}
className="text-xs sm:text-sm"
>
<Check className={cn("mr-2 h-4 w-4", isSelected ? "opacity-100" : "opacity-0")} />
<div className="flex flex-col">
<span className="font-medium">{option[displayField]}</span>
{valueField !== displayField && (
<span className="text-muted-foreground text-[10px]">{option[valueField]}</span>
)}
</div>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
{/* 닫기 버튼 */}
<div className="border-t p-2">
<Button variant="outline" size="sm" onClick={() => setSelectOpen(false)} className="w-full text-xs">
</Button>
</div>
</div>
)}
{/* 외부 클릭 시 닫기 */}
{selectOpen && <div className="fixed inset-0 z-40" onClick={() => setSelectOpen(false)} />}
</div>
);
}
// 단일선택 모드 (기존 로직)
return (
<div className={cn("relative flex flex-col", className)} style={style}>
{/* 라벨 렌더링 */}
@@ -366,6 +547,95 @@ export function EntitySearchInputComponent({
}
// modal, combo, autocomplete 모드
// 다중선택 모드
if (isMultiple) {
return (
<div className={cn("relative flex flex-col", className)} style={style}>
{/* 라벨 렌더링 */}
{component?.label && component?.style?.labelDisplay !== false && (
<label className="text-muted-foreground absolute -top-6 left-0 text-sm font-medium">
{component.label}
{component.required && <span className="text-destructive">*</span>}
</label>
)}
{/* 선택된 항목들 표시 (태그 형식) + 검색 버튼 */}
<div className="flex h-full gap-2">
<div
className={cn(
"box-border flex min-h-[40px] flex-1 flex-wrap items-center gap-2 rounded-md border bg-white px-3 py-2",
!disabled && "hover:border-primary/50",
disabled && "cursor-not-allowed bg-gray-100 opacity-60",
)}
>
{selectedValues.length > 0 ? (
selectedValues.map((val) => {
// selectedDataList에서 먼저 찾고, 없으면 effectiveOptions에서 찾기
const dataFromList = selectedDataList.find((d) => String(d[valueField]) === val);
const opt = dataFromList || effectiveOptions.find((o) => String(o[valueField]) === val);
const label = opt?.[displayField] || val;
return (
<span
key={val}
className="flex items-center gap-1 rounded bg-blue-100 px-2 py-1 text-sm text-blue-800"
>
{label}
{!disabled && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleRemoveValue(val);
}}
className="ml-1 text-blue-600 hover:text-blue-800"
>
×
</button>
)}
</span>
);
})
) : (
<span className="text-muted-foreground text-sm">{placeholder}</span>
)}
</div>
{/* 모달 버튼: modal 또는 combo 모드일 때만 표시 */}
{(mode === "modal" || mode === "combo") && (
<Button
type="button"
onClick={handleOpenModal}
disabled={disabled}
className={cn(!componentHeight && "h-8 text-xs sm:h-10 sm:text-sm")}
style={inputStyle}
>
<Search className="h-4 w-4" />
</Button>
)}
</div>
{/* 검색 모달: modal 또는 combo 모드일 때만 렌더링 */}
{(mode === "modal" || mode === "combo") && (
<EntitySearchModal
open={modalOpen}
onOpenChange={setModalOpen}
tableName={tableName}
displayField={displayField}
valueField={valueField}
searchFields={searchFields}
filterCondition={filterCondition}
modalTitle={modalTitle}
modalColumns={modalColumns}
onSelect={handleSelect}
multiple={isMultiple}
selectedValues={selectedValues}
/>
)}
</div>
);
}
// 단일선택 모드 (기존 로직)
return (
<div className={cn("relative flex flex-col", className)} style={style}>
{/* 라벨 렌더링 */}