엔티티 즉시저장기능 추가
This commit is contained in:
@@ -3,17 +3,32 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Search, X } from "lucide-react";
|
||||
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";
|
||||
|
||||
export function EntitySearchInputComponent({
|
||||
tableName,
|
||||
displayField,
|
||||
valueField,
|
||||
searchFields = [displayField],
|
||||
mode = "combo",
|
||||
mode: modeProp,
|
||||
uiMode, // EntityConfigPanel에서 저장되는 값
|
||||
placeholder = "검색...",
|
||||
disabled = false,
|
||||
filterCondition = {},
|
||||
@@ -24,31 +39,99 @@ export function EntitySearchInputComponent({
|
||||
showAdditionalInfo = false,
|
||||
additionalFields = [],
|
||||
className,
|
||||
}: EntitySearchInputProps) {
|
||||
style,
|
||||
// 🆕 추가 props
|
||||
component,
|
||||
isInteractive,
|
||||
onFormDataChange,
|
||||
}: EntitySearchInputProps & {
|
||||
uiMode?: string;
|
||||
component?: any;
|
||||
isInteractive?: boolean;
|
||||
onFormDataChange?: (fieldName: string, value: any) => void;
|
||||
}) {
|
||||
// uiMode가 있으면 우선 사용, 없으면 modeProp 사용, 기본값 "combo"
|
||||
const mode = (uiMode || modeProp || "combo") as "select" | "modal" | "combo" | "autocomplete";
|
||||
|
||||
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);
|
||||
|
||||
// filterCondition을 문자열로 변환하여 비교 (객체 참조 문제 해결)
|
||||
const filterConditionKey = JSON.stringify(filterCondition || {});
|
||||
|
||||
// select 모드일 때 옵션 로드 (한 번만)
|
||||
useEffect(() => {
|
||||
if (mode === "select" && tableName && !optionsLoaded) {
|
||||
loadOptions();
|
||||
setOptionsLoaded(true);
|
||||
}
|
||||
}, [mode, tableName, filterConditionKey, optionsLoaded]);
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
// value가 변경되면 표시값 업데이트
|
||||
useEffect(() => {
|
||||
if (value && selectedData) {
|
||||
setDisplayValue(selectedData[displayField] || "");
|
||||
} else {
|
||||
} else if (value && mode === "select" && options.length > 0) {
|
||||
// select 모드에서 value가 있고 options가 로드된 경우
|
||||
const found = options.find(opt => opt[valueField] === value);
|
||||
if (found) {
|
||||
setSelectedData(found);
|
||||
setDisplayValue(found[displayField] || "");
|
||||
}
|
||||
} else if (!value) {
|
||||
setDisplayValue("");
|
||||
setSelectedData(null);
|
||||
}
|
||||
}, [value, displayField]);
|
||||
}, [value, displayField, options, mode, valueField]);
|
||||
|
||||
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 = () => {
|
||||
@@ -57,10 +140,105 @@ export function EntitySearchInputComponent({
|
||||
}
|
||||
};
|
||||
|
||||
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("flex flex-col", className)} style={style}>
|
||||
<Popover open={selectOpen} onOpenChange={setSelectOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={selectOpen}
|
||||
disabled={disabled || isLoadingOptions}
|
||||
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}
|
||||
>
|
||||
{isLoadingOptions
|
||||
? "로딩 중..."
|
||||
: 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="text-xs sm:text-sm py-4 text-center">
|
||||
항목을 찾을 수 없습니다.
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{options.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-[10px] text-muted-foreground">
|
||||
{option[valueField]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* 추가 정보 표시 */}
|
||||
{showAdditionalInfo && selectedData && additionalFields.length > 0 && (
|
||||
<div className="text-xs text-muted-foreground space-y-1 px-2 mt-1">
|
||||
{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("space-y-2", className)}>
|
||||
<div className={cn("flex flex-col", className)} style={style}>
|
||||
{/* 입력 필드 */}
|
||||
<div className="flex gap-2">
|
||||
<div className="flex gap-2 h-full">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
value={displayValue}
|
||||
@@ -68,7 +246,8 @@ export function EntitySearchInputComponent({
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
readOnly={mode === "modal" || mode === "combo"}
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm pr-8"
|
||||
className={cn("w-full pr-8", !componentHeight && "h-8 text-xs sm:h-10 sm:text-sm")}
|
||||
style={inputStyle}
|
||||
/>
|
||||
{displayValue && !disabled && (
|
||||
<Button
|
||||
@@ -83,12 +262,14 @@ export function EntitySearchInputComponent({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 모달 버튼: modal 또는 combo 모드일 때만 표시 */}
|
||||
{(mode === "modal" || mode === "combo") && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleOpenModal}
|
||||
disabled={disabled}
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
className={cn(!componentHeight && "h-8 text-xs sm:h-10 sm:text-sm")}
|
||||
style={inputStyle}
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -97,7 +278,7 @@ export function EntitySearchInputComponent({
|
||||
|
||||
{/* 추가 정보 표시 */}
|
||||
{showAdditionalInfo && selectedData && additionalFields.length > 0 && (
|
||||
<div className="text-xs text-muted-foreground space-y-1 px-2">
|
||||
<div className="text-xs text-muted-foreground space-y-1 px-2 mt-1">
|
||||
{additionalFields.map((field) => (
|
||||
<div key={field} className="flex gap-2">
|
||||
<span className="font-medium">{field}:</span>
|
||||
@@ -107,19 +288,21 @@ export function EntitySearchInputComponent({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 검색 모달 */}
|
||||
<EntitySearchModal
|
||||
open={modalOpen}
|
||||
onOpenChange={setModalOpen}
|
||||
tableName={tableName}
|
||||
displayField={displayField}
|
||||
valueField={valueField}
|
||||
searchFields={searchFields}
|
||||
filterCondition={filterCondition}
|
||||
modalTitle={modalTitle}
|
||||
modalColumns={modalColumns}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
{/* 검색 모달: 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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user