446 lines
17 KiB
TypeScript
446 lines
17 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect, useRef } from "react";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Search, X, Check, ChevronsUpDown } from "lucide-react";
|
|
import { EntitySearchModal } from "./EntitySearchModal";
|
|
import { EntitySearchInputProps, EntitySearchResult } from "./types";
|
|
import { cn } from "@/lib/utils";
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
|
import { dynamicFormApi } from "@/lib/api/dynamicForm";
|
|
import { cascadingRelationApi } from "@/lib/api/cascadingRelation";
|
|
|
|
export function EntitySearchInputComponent({
|
|
tableName,
|
|
displayField,
|
|
valueField,
|
|
searchFields = [displayField],
|
|
mode: modeProp,
|
|
uiMode, // EntityConfigPanel에서 저장되는 값
|
|
placeholder = "검색...",
|
|
disabled = false,
|
|
filterCondition = {},
|
|
value,
|
|
onChange,
|
|
modalTitle = "검색",
|
|
modalColumns = [],
|
|
showAdditionalInfo = false,
|
|
additionalFields = [],
|
|
className,
|
|
style,
|
|
// 연쇄관계 props
|
|
cascadingRelationCode,
|
|
parentValue: parentValueProp,
|
|
parentFieldId,
|
|
formData,
|
|
// 🆕 추가 props
|
|
component,
|
|
isInteractive,
|
|
onFormDataChange,
|
|
}: EntitySearchInputProps & {
|
|
uiMode?: string;
|
|
component?: any;
|
|
isInteractive?: boolean;
|
|
onFormDataChange?: (fieldName: string, value: any) => void;
|
|
webTypeConfig?: any; // 웹타입 설정 (연쇄관계 등)
|
|
}) {
|
|
// uiMode가 있으면 우선 사용, 없으면 modeProp 사용, 기본값 "combo"
|
|
const mode = (uiMode || modeProp || "combo") as "select" | "modal" | "combo" | "autocomplete";
|
|
|
|
// 연쇄관계 설정 추출 (webTypeConfig 또는 component.componentConfig에서)
|
|
const config = component?.componentConfig || {};
|
|
const effectiveCascadingRelationCode = cascadingRelationCode || config.cascadingRelationCode;
|
|
const effectiveParentFieldId = parentFieldId || config.parentFieldId;
|
|
const effectiveCascadingRole = config.cascadingRole; // "parent" | "child" | undefined
|
|
|
|
// 부모 역할이면 연쇄관계 로직 적용 안함 (자식만 부모 값에 따라 필터링됨)
|
|
const isChildRole = effectiveCascadingRole === "child";
|
|
const shouldApplyCascading = effectiveCascadingRelationCode && isChildRole;
|
|
|
|
const [modalOpen, setModalOpen] = useState(false);
|
|
const [selectOpen, setSelectOpen] = useState(false);
|
|
const [displayValue, setDisplayValue] = useState("");
|
|
const [selectedData, setSelectedData] = useState<EntitySearchResult | null>(null);
|
|
const [options, setOptions] = useState<EntitySearchResult[]>([]);
|
|
const [isLoadingOptions, setIsLoadingOptions] = useState(false);
|
|
const [optionsLoaded, setOptionsLoaded] = useState(false);
|
|
|
|
// 연쇄관계 상태
|
|
const [cascadingOptions, setCascadingOptions] = useState<EntitySearchResult[]>([]);
|
|
const [isCascadingLoading, setIsCascadingLoading] = useState(false);
|
|
const previousParentValue = useRef<any>(null);
|
|
|
|
// 부모 필드 값 결정 (직접 전달 또는 formData에서 추출) - 자식 역할일 때만 필요
|
|
const parentValue = isChildRole
|
|
? (parentValueProp ?? (effectiveParentFieldId && formData ? formData[effectiveParentFieldId] : undefined))
|
|
: undefined;
|
|
|
|
// filterCondition을 문자열로 변환하여 비교 (객체 참조 문제 해결)
|
|
const filterConditionKey = JSON.stringify(filterCondition || {});
|
|
|
|
// 연쇄관계가 설정된 경우: 부모 값이 변경되면 자식 옵션 로드 (자식 역할일 때만)
|
|
useEffect(() => {
|
|
const loadCascadingOptions = async () => {
|
|
if (!shouldApplyCascading) return;
|
|
|
|
// 부모 값이 없으면 옵션 초기화
|
|
if (!parentValue) {
|
|
setCascadingOptions([]);
|
|
// 부모 값이 변경되면 현재 값도 초기화
|
|
if (previousParentValue.current !== null && previousParentValue.current !== parentValue) {
|
|
handleClear();
|
|
}
|
|
previousParentValue.current = parentValue;
|
|
return;
|
|
}
|
|
|
|
// 부모 값이 동일하면 스킵
|
|
if (previousParentValue.current === parentValue) {
|
|
return;
|
|
}
|
|
|
|
previousParentValue.current = parentValue;
|
|
setIsCascadingLoading(true);
|
|
|
|
try {
|
|
console.log("🔗 연쇄관계 옵션 로드:", { effectiveCascadingRelationCode, parentValue });
|
|
const response = await cascadingRelationApi.getOptions(effectiveCascadingRelationCode, String(parentValue));
|
|
|
|
if (response.success && response.data) {
|
|
// 옵션을 EntitySearchResult 형태로 변환
|
|
const formattedOptions = response.data.map((opt: any) => ({
|
|
[valueField]: opt.value,
|
|
[displayField]: opt.label,
|
|
...opt, // 추가 필드도 포함
|
|
}));
|
|
setCascadingOptions(formattedOptions);
|
|
console.log("✅ 연쇄관계 옵션 로드 완료:", formattedOptions.length, "개");
|
|
|
|
// 현재 선택된 값이 새 옵션에 없으면 초기화
|
|
if (value && !formattedOptions.find((opt: any) => opt[valueField] === value)) {
|
|
handleClear();
|
|
}
|
|
} else {
|
|
setCascadingOptions([]);
|
|
}
|
|
} catch (error) {
|
|
console.error("❌ 연쇄관계 옵션 로드 실패:", error);
|
|
setCascadingOptions([]);
|
|
} finally {
|
|
setIsCascadingLoading(false);
|
|
}
|
|
};
|
|
|
|
loadCascadingOptions();
|
|
}, [shouldApplyCascading, effectiveCascadingRelationCode, parentValue, valueField, displayField]);
|
|
|
|
// select 모드일 때 옵션 로드 (연쇄관계가 없거나 부모 역할인 경우)
|
|
useEffect(() => {
|
|
if (mode === "select" && tableName && !optionsLoaded && !shouldApplyCascading) {
|
|
loadOptions();
|
|
setOptionsLoaded(true);
|
|
}
|
|
}, [mode, tableName, filterConditionKey, optionsLoaded, shouldApplyCascading]);
|
|
|
|
const loadOptions = async () => {
|
|
if (!tableName) return;
|
|
|
|
setIsLoadingOptions(true);
|
|
try {
|
|
const response = await dynamicFormApi.getTableData(tableName, {
|
|
page: 1,
|
|
pageSize: 100, // 최대 100개까지 로드
|
|
filters: filterCondition,
|
|
});
|
|
|
|
if (response.success && response.data) {
|
|
setOptions(response.data);
|
|
}
|
|
} catch (error) {
|
|
console.error("옵션 로드 실패:", error);
|
|
} finally {
|
|
setIsLoadingOptions(false);
|
|
}
|
|
};
|
|
|
|
// 실제 사용할 옵션 목록 (자식 역할이고 연쇄관계가 있으면 연쇄 옵션 사용)
|
|
const effectiveOptions = shouldApplyCascading ? cascadingOptions : options;
|
|
const isLoading = shouldApplyCascading ? isCascadingLoading : isLoadingOptions;
|
|
|
|
// value가 변경되면 표시값 업데이트 (외래키 값으로 데이터 조회)
|
|
useEffect(() => {
|
|
const loadDisplayValue = async () => {
|
|
// value가 없으면 초기화
|
|
if (!value) {
|
|
setDisplayValue("");
|
|
setSelectedData(null);
|
|
return;
|
|
}
|
|
|
|
// 이미 selectedData가 있고 value와 일치하면 표시값만 업데이트
|
|
if (selectedData && String(selectedData[valueField]) === String(value)) {
|
|
setDisplayValue(selectedData[displayField] || "");
|
|
return;
|
|
}
|
|
|
|
// select 모드에서 options가 로드된 경우 먼저 옵션에서 찾기
|
|
if (mode === "select" && effectiveOptions.length > 0) {
|
|
// 타입 변환하여 비교 (숫자 vs 문자열 문제 해결)
|
|
const found = effectiveOptions.find((opt) => String(opt[valueField]) === String(value));
|
|
if (found) {
|
|
setSelectedData(found);
|
|
setDisplayValue(found[displayField] || "");
|
|
console.log("✅ [EntitySearchInput] 옵션에서 초기값 찾음:", { value, found });
|
|
return;
|
|
}
|
|
// 옵션에서 찾지 못한 경우 API로 조회 진행
|
|
console.log("⚠️ [EntitySearchInput] 옵션에서 찾지 못함, API로 조회:", {
|
|
value,
|
|
optionsCount: effectiveOptions.length,
|
|
});
|
|
}
|
|
|
|
// API로 해당 데이터 조회
|
|
if (tableName) {
|
|
try {
|
|
console.log("🔍 [EntitySearchInput] 초기값 조회:", { value, tableName, valueField });
|
|
const response = await dynamicFormApi.getTableData(tableName, {
|
|
filters: { [valueField]: value },
|
|
pageSize: 1,
|
|
});
|
|
|
|
if (response.success && response.data) {
|
|
// 데이터 추출 (중첩 구조 처리)
|
|
const responseData = response.data as any;
|
|
const dataArray = Array.isArray(responseData)
|
|
? responseData
|
|
: responseData?.data
|
|
? Array.isArray(responseData.data)
|
|
? responseData.data
|
|
: [responseData.data]
|
|
: [];
|
|
|
|
if (dataArray.length > 0) {
|
|
const foundData = dataArray[0];
|
|
setSelectedData(foundData);
|
|
setDisplayValue(foundData[displayField] || "");
|
|
console.log("✅ [EntitySearchInput] 초기값 로드 완료:", foundData);
|
|
} else {
|
|
// 데이터를 찾지 못한 경우 value 자체를 표시
|
|
console.log("⚠️ [EntitySearchInput] 초기값 데이터 없음, value 표시:", value);
|
|
setDisplayValue(String(value));
|
|
}
|
|
} else {
|
|
console.log("⚠️ [EntitySearchInput] API 응답 실패, value 표시:", value);
|
|
setDisplayValue(String(value));
|
|
}
|
|
} catch (error) {
|
|
console.error("❌ [EntitySearchInput] 초기값 조회 실패:", error);
|
|
// 에러 시 value 자체를 표시
|
|
setDisplayValue(String(value));
|
|
}
|
|
}
|
|
};
|
|
|
|
loadDisplayValue();
|
|
}, [value, displayField, effectiveOptions, mode, valueField, tableName, selectedData]);
|
|
|
|
const handleSelect = (newValue: any, fullData: EntitySearchResult) => {
|
|
setSelectedData(fullData);
|
|
setDisplayValue(fullData[displayField] || "");
|
|
onChange?.(newValue, fullData);
|
|
|
|
// 🆕 onFormDataChange 호출 (formData에 값 저장)
|
|
if (isInteractive && onFormDataChange && component?.columnName) {
|
|
onFormDataChange(component.columnName, newValue);
|
|
console.log("📤 EntitySearchInput -> onFormDataChange:", component.columnName, newValue);
|
|
}
|
|
};
|
|
|
|
const handleClear = () => {
|
|
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);
|
|
}
|
|
};
|
|
|
|
const handleOpenModal = () => {
|
|
if (!disabled) {
|
|
setModalOpen(true);
|
|
}
|
|
};
|
|
|
|
const handleSelectOption = (option: EntitySearchResult) => {
|
|
handleSelect(option[valueField], option);
|
|
setSelectOpen(false);
|
|
};
|
|
|
|
// 높이 계산 (style에서 height가 있으면 사용, 없으면 기본값)
|
|
const componentHeight = style?.height;
|
|
const inputStyle: React.CSSProperties = componentHeight ? { height: componentHeight } : {};
|
|
|
|
// select 모드: 검색 가능한 드롭다운
|
|
if (mode === "select") {
|
|
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>
|
|
)}
|
|
<Popover open={selectOpen} onOpenChange={setSelectOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
role="combobox"
|
|
aria-expanded={selectOpen}
|
|
disabled={disabled || isLoading || Boolean(shouldApplyCascading && !parentValue)}
|
|
className={cn(
|
|
"w-full justify-between font-normal",
|
|
!componentHeight && "h-8 text-xs sm:h-10 sm:text-sm",
|
|
!value && "text-muted-foreground",
|
|
)}
|
|
style={inputStyle}
|
|
>
|
|
{isLoading
|
|
? "로딩 중..."
|
|
: shouldApplyCascading && !parentValue
|
|
? "상위 항목을 먼저 선택하세요"
|
|
: displayValue || placeholder}
|
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
|
<Command>
|
|
<CommandInput placeholder={`${displayField} 검색...`} className="text-xs sm:text-sm" />
|
|
<CommandList>
|
|
<CommandEmpty className="py-4 text-center text-xs sm:text-sm">항목을 찾을 수 없습니다.</CommandEmpty>
|
|
<CommandGroup>
|
|
{effectiveOptions.map((option, index) => (
|
|
<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", value === option[valueField] ? "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>
|
|
</PopoverContent>
|
|
</Popover>
|
|
|
|
{/* 추가 정보 표시 */}
|
|
{showAdditionalInfo && selectedData && additionalFields.length > 0 && (
|
|
<div className="text-muted-foreground mt-1 space-y-1 px-2 text-xs">
|
|
{additionalFields.map((field) => (
|
|
<div key={field} className="flex gap-2">
|
|
<span className="font-medium">{field}:</span>
|
|
<span>{selectedData[field] || "-"}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// modal, combo, autocomplete 모드
|
|
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="relative flex-1">
|
|
<Input
|
|
value={displayValue}
|
|
onChange={(e) => setDisplayValue(e.target.value)}
|
|
placeholder={placeholder}
|
|
disabled={disabled}
|
|
readOnly={mode === "modal" || mode === "combo"}
|
|
className={cn("w-full pr-8", !componentHeight && "h-8 text-xs sm:h-10 sm:text-sm")}
|
|
style={inputStyle}
|
|
/>
|
|
{displayValue && !disabled && (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={handleClear}
|
|
className="absolute top-1/2 right-1 h-6 w-6 -translate-y-1/2 p-0"
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</Button>
|
|
)}
|
|
</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>
|
|
|
|
{/* 추가 정보 표시 */}
|
|
{showAdditionalInfo && selectedData && additionalFields.length > 0 && (
|
|
<div className="text-muted-foreground mt-1 space-y-1 px-2 text-xs">
|
|
{additionalFields.map((field) => (
|
|
<div key={field} className="flex gap-2">
|
|
<span className="font-medium">{field}:</span>
|
|
<span>{selectedData[field] || "-"}</span>
|
|
</div>
|
|
))}
|
|
</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}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|