엔티티타입 입력 셀렉트박스 다중선택 기능
This commit is contained in:
@@ -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}>
|
||||
{/* 라벨 렌더링 */}
|
||||
|
||||
Reference in New Issue
Block a user